10 Commits
25 changed files with 1699 additions and 126 deletions
+319 -40
View File
@@ -49,6 +49,8 @@ import (
"hamlog/internal/qslcard" "hamlog/internal/qslcard"
"hamlog/internal/qso" "hamlog/internal/qso"
"hamlog/internal/rotator/pst" "hamlog/internal/rotator/pst"
"hamlog/internal/relaydev"
"hamlog/internal/rotgenius"
"hamlog/internal/settings" "hamlog/internal/settings"
"hamlog/internal/solar" "hamlog/internal/solar"
"hamlog/internal/steppir" "hamlog/internal/steppir"
@@ -154,6 +156,8 @@ const (
keyRotatorHost = "rotator.host" keyRotatorHost = "rotator.host"
keyRotatorPort = "rotator.port" keyRotatorPort = "rotator.port"
keyRotatorHasElevation = "rotator.has_elevation" 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 // Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
// "ultrabeam." prefix for backward compatibility with configs saved before the // "ultrabeam." prefix for backward compatibility with configs saved before the
@@ -162,12 +166,14 @@ const (
keyUltrabeamEnabled = "ultrabeam.enabled" keyUltrabeamEnabled = "ultrabeam.enabled"
keyUltrabeamHost = "ultrabeam.host" keyUltrabeamHost = "ultrabeam.host"
keyUltrabeamPort = "ultrabeam.port" keyUltrabeamPort = "ultrabeam.port"
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam) keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp) keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0) keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600) 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 // Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
// port is fixed at 9007, so only the IP is configurable. // port is fixed at 9007, so only the IP is configurable.
@@ -441,6 +447,9 @@ type App struct {
clublog *clublog.Manager clublog *clublog.Manager
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window)
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
audioMgr *audio.Manager audioMgr *audio.Manager
@@ -2090,6 +2099,11 @@ func (a *App) applyStationDefaults(q *qso.QSO, includeIdentity bool) {
if q.MyPOTARef == "" { if q.MyPOTARef == "" {
q.MyPOTARef = p.MyPOTARef q.MyPOTARef = p.MyPOTARef
} }
// MY_NAME = the operator's personal name (profile OpName, e.g. "Greg") — stamped
// like the other My* fields so every QSO carries it, not just those edited by hand.
if q.MyName == "" {
q.MyName = p.OpName
}
// Per-band rig/antenna from Operating conditions (the antenna ticked as // Per-band rig/antenna from Operating conditions (the antenna ticked as
// DEFAULT for this band) — the same auto-fill the entry strip does, applied // DEFAULT for this band) — the same auto-fill the entry strip does, applied
// here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults. // here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults.
@@ -4651,6 +4665,10 @@ var bulkFieldColumns = map[string]string{
"my_antenna": "my_antenna", "my_antenna": "my_antenna",
"my_sig": "my_sig", "my_sig": "my_sig",
"my_sig_info": "my_sig_info", "my_sig_info": "my_sig_info",
"my_name": "my_name",
"my_arrl_sect": "my_arrl_sect",
"my_darc_dok": "my_darc_dok",
"my_vucc_grids": "my_vucc_grids",
// Contest // Contest
"contest_id": "contest_id", "contest_id": "contest_id",
"srx_string": "srx_string", "srx_string": "srx_string",
@@ -8878,6 +8896,12 @@ func (a *App) consumeUDPEvents() {
"source": ev.Source, "source": ev.Source,
"adif": ev.LoggedADIF, "adif": ev.LoggedADIF,
}) })
case ev.ClearCall:
applog.Printf("udp: emit udp:clear_call (DX Call cleared in the digital app)\n")
wruntime.EventsEmit(a.ctx, "udp:clear_call", map[string]any{
"service": string(ev.Service),
"source": ev.Source,
})
case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall: case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall:
applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall) applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall)
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall) wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
@@ -9232,6 +9256,7 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step) applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
return // within the deadband — don't chase a tiny QSY return // within the deadband — don't chase a tiny QSY
} }
a.noteMotorMoveCommanded()
if err := c.SetFrequency(khz, st.Direction); err != nil { if err := c.SetFrequency(khz, st.Direction); err != nil {
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err) applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
} else { } else {
@@ -10373,30 +10398,40 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error
// RotatorSettings is the JSON shape for the Hardware → Rotator panel. // RotatorSettings is the JSON shape for the Hardware → Rotator panel.
type RotatorSettings struct { type RotatorSettings struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP)
Host string `json:"host"` // default 127.0.0.1 Host string `json:"host"` // default 127.0.0.1
Port int `json:"port"` // default 12000 Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius)
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets 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. // GetRotatorSettings returns the persisted rotator config with defaults.
func (a *App) GetRotatorSettings() (RotatorSettings, error) { 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 { if a.settings == nil {
return out, fmt.Errorf("db not initialized") return out, fmt.Errorf("db not initialized")
} }
m, err := a.settings.GetMany(a.ctx, m, err := a.settings.GetMany(a.ctx,
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation) keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum)
if err != nil { if err != nil {
return out, err return out, err
} }
out.Enabled = m[keyRotatorEnabled] == "1" out.Enabled = m[keyRotatorEnabled] == "1"
if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" {
out.Type = t
}
if h := m[keyRotatorHost]; h != "" { if h := m[keyRotatorHost]; h != "" {
out.Host = h out.Host = h
} }
if p, _ := strconv.Atoi(m[keyRotatorPort]); p > 0 && p <= 65535 { if p, _ := strconv.Atoi(m[keyRotatorPort]); p > 0 && p <= 65535 {
out.Port = p out.Port = p
} else if out.Type == "rotgenius" {
out.Port = 9006 // native default when no port stored yet
} }
out.HasElevation = m[keyRotatorHasElevation] == "1" out.HasElevation = m[keyRotatorHasElevation] == "1"
if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 {
out.RotatorNum = 2
}
return out, nil return out, nil
} }
@@ -10406,17 +10441,25 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
if a.settings == nil { if a.settings == nil {
return fmt.Errorf("db not initialized") return fmt.Errorf("db not initialized")
} }
if s.Type != "rotgenius" {
s.Type = "pst"
}
if s.Host == "" { if s.Host == "" {
s.Host = "127.0.0.1" s.Host = "127.0.0.1"
} }
if s.Port <= 0 || s.Port > 65535 { 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{ for k, v := range map[string]string{
keyRotatorEnabled: boolStr(s.Enabled), keyRotatorEnabled: boolStr(s.Enabled),
keyRotatorType: s.Type,
keyRotatorHost: s.Host, keyRotatorHost: s.Host,
keyRotatorPort: strconv.Itoa(s.Port), keyRotatorPort: strconv.Itoa(s.Port),
keyRotatorHasElevation: boolStr(s.HasElevation), keyRotatorHasElevation: boolStr(s.HasElevation),
keyRotatorNum: strconv.Itoa(s.RotatorNum),
} { } {
if err := a.settings.Set(a.ctx, k, v); err != nil { if err := a.settings.Set(a.ctx, k, v); err != nil {
return err return err
@@ -10425,18 +10468,6 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
return nil 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. // RotatorHeading is the live antenna heading for the status bar.
type RotatorHeading struct { type RotatorHeading struct {
@@ -10453,6 +10484,16 @@ func (a *App) GetRotatorHeading() RotatorHeading {
if err != nil || !s.Enabled { if err != nil || !s.Enabled {
return RotatorHeading{Enabled: false} 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() az, raw, herr := pst.New(s.Host, s.Port).Heading()
if herr != nil { if herr != nil {
return RotatorHeading{Enabled: true, OK: false, Raw: raw} return RotatorHeading{Enabled: true, OK: false, Raw: raw}
@@ -10463,30 +10504,49 @@ func (a *App) GetRotatorHeading() RotatorHeading {
// RotatorGoTo points the antenna at the given azimuth (and optional // RotatorGoTo points the antenna at the given azimuth (and optional
// elevation if the rotator is configured for it). // elevation if the rotator is configured for it).
func (a *App) RotatorGoTo(az int, el int) error { func (a *App) RotatorGoTo(az int, el int) error {
c, s, err := a.rotatorClient() s, err := a.GetRotatorSettings()
if err != nil { if err != nil {
return err 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 { func (a *App) RotatorStop() error {
c, _, err := a.rotatorClient() s, err := a.GetRotatorSettings()
if err != nil { if err != nil {
return err 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 // 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 { func (a *App) RotatorPark() error {
c, _, err := a.rotatorClient() s, err := a.GetRotatorSettings()
if err != nil { if err != nil {
return err 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 // TestRotator sends a no-op GoTo to the rotator's current heading to
@@ -10497,6 +10557,19 @@ func (a *App) TestRotator(s RotatorSettings) error {
if s.Host == "" { if s.Host == "" {
s.Host = "127.0.0.1" 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 { if s.Port <= 0 || s.Port > 65535 {
s.Port = 12000 s.Port = 12000
} }
@@ -10510,6 +10583,146 @@ func boolStr(b bool) string {
return "0" 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) ──────────────────────────── // ── Motorized antenna (Ultrabeam / SteppIR) ────────────────────────────
// motorAntenna is the shared control surface of a motorized antenna. The // motorAntenna is the shared control surface of a motorized antenna. The
@@ -10575,14 +10788,15 @@ func (a steppirAdapter) Status() motorStatus {
// is kept (bindings + frontend) though it now covers SteppIR too. // is kept (bindings + frontend) though it now covers SteppIR too.
type UltrabeamSettings struct { type UltrabeamSettings struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Type string `json:"type"` // "ultrabeam" | "steppir" Type string `json:"type"` // "ultrabeam" | "steppir"
Transport string `json:"transport"` // "tcp" | "serial" Transport string `json:"transport"` // "tcp" | "serial"
Host string `json:"host"` // tcp Host string `json:"host"` // tcp
Port int `json:"port"` // tcp Port int `json:"port"` // tcp
COM string `json:"com"` // serial device COM string `json:"com"` // serial device
Baud int `json:"baud"` // serial baud Baud int `json:"baud"` // serial baud
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100) StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
TXInhibit bool `json:"tx_inhibit"` // block Flex transmission while the elements are moving
} }
// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting // GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
@@ -10594,7 +10808,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
return out, fmt.Errorf("db not initialized") return out, fmt.Errorf("db not initialized")
} }
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep, m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud) keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit)
if err != nil { if err != nil {
return out, err return out, err
} }
@@ -10614,6 +10828,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
out.Baud = b out.Baud = b
} }
out.Follow = m[keyUltrabeamFollow] == "1" out.Follow = m[keyUltrabeamFollow] == "1"
out.TXInhibit = m[keyMotorTXInhibit] == "1"
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 { if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
out.StepKHz = st out.StepKHz = st
} }
@@ -10651,6 +10866,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
keyMotorTransport: s.Transport, keyMotorTransport: s.Transport,
keyMotorCOM: strings.TrimSpace(s.COM), keyMotorCOM: strings.TrimSpace(s.COM),
keyMotorBaud: strconv.Itoa(s.Baud), keyMotorBaud: strconv.Itoa(s.Baud),
keyMotorTXInhibit: boolStr(s.TXInhibit),
} { } {
if err := a.settings.Set(a.ctx, k, v); err != nil { if err := a.settings.Set(a.ctx, k, v); err != nil {
return err return err
@@ -10690,6 +10906,11 @@ func (a *App) startUltrabeam() {
close(a.ubFollowStop) close(a.ubFollowStop)
a.ubFollowStop = nil a.ubFollowStop = nil
} }
// Stop the inhibit watcher (its deferred cleanup releases any active inhibit).
if a.motorInhibStop != nil {
close(a.motorInhibStop)
a.motorInhibStop = nil
}
if a.motorAnt != nil { if a.motorAnt != nil {
// Background teardown so saving Settings doesn't block on an in-progress // Background teardown so saving Settings doesn't block on an in-progress
// connect (Stop waits for the dial timeout). // connect (Stop waits for the dial timeout).
@@ -10711,12 +10932,68 @@ func (a *App) startUltrabeam() {
a.ubFollowStop = stop a.ubFollowStop = stop
go a.ultrabeamFollowLoop(a.motorAnt, s.StepKHz, stop) go a.ultrabeamFollowLoop(a.motorAnt, s.StepKHz, stop)
} }
if s.TXInhibit {
stop := make(chan struct{})
a.motorInhibStop = stop
go a.motorTXInhibitLoop(a.motorAnt, stop)
}
} }
// ultrabeamFollowLoop re-tunes the antenna to the rig's current frequency // ultrabeamFollowLoop re-tunes the antenna to the rig's current frequency
// whenever it drifts at least stepKHz from what the antenna is set to — so the // whenever it drifts at least stepKHz from what the antenna is set to — so the
// elements track the band without the motors chasing every small QSY. Runs // elements track the band without the motors chasing every small QSY. Runs
// until stop is closed (a settings change or shutdown). // until stop is closed (a settings change or shutdown).
// noteMotorMoveCommanded marks that OpsLog just told the antenna to move. The
// inhibit watcher polls the antenna's Moving status, but that status is only
// refreshed every couple of seconds — so a fresh command opens a short grace
// window during which TX stays inhibited even before the poll confirms motion,
// closing the gap at the start of a move.
func (a *App) noteMotorMoveCommanded() { a.motorMoveCmdNs.Store(time.Now().UnixNano()) }
// applyMotorInhibit sets or clears the Flex transmit inhibit, but only when the
// active CAT backend IS a FlexRadio — the inhibit is a Flex-API feature; with any
// other rig the option simply does nothing (as documented in the UI). Idempotent.
func (a *App) applyMotorInhibit(on bool) {
if a.cat == nil {
return
}
if a.cat.State().Backend != "flex" {
return
}
if a.motorInhibited.Load() == on {
return
}
if err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXInhibit(on) }); err != nil {
applog.Printf("motor-antenna: TX inhibit=%v failed: %v", on, err)
return
}
a.motorInhibited.Store(on)
applog.Printf("motor-antenna: Flex TX inhibit %s (antenna moving)", map[bool]string{true: "ON", false: "OFF"}[on])
}
// motorTXInhibitLoop blocks Flex transmission while the motorized antenna's
// elements are moving. Runs only when the option is on and a motor antenna is
// active; it errs toward SAFE — TX is inhibited while the status reports motion
// OR within a grace window after a commanded move — and always releases the
// inhibit when it stops (so a settings change / shutdown never leaves TX blocked).
func (a *App) motorTXInhibitLoop(c motorAntenna, stop <-chan struct{}) {
const grace = 3 * time.Second // cover the poll latency at the very start of a move
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
defer a.applyMotorInhibit(false) // never leave TX blocked when the loop ends
for {
select {
case <-stop:
return
case <-ticker.C:
st := c.Status()
recentCmd := time.Since(time.Unix(0, a.motorMoveCmdNs.Load())) < grace
moving := st.Connected && (st.Moving || recentCmd)
a.applyMotorInhibit(moving)
}
}
}
func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struct{}) { func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struct{}) {
if stepKHz <= 0 { if stepKHz <= 0 {
stepKHz = 50 stepKHz = 50
@@ -10771,6 +11048,7 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struc
if ref > 0 && diff < stepKHz { if ref > 0 && diff < stepKHz {
continue // within the deadband — leave the motors alone continue // within the deadband — leave the motors alone
} }
a.noteMotorMoveCommanded()
if err := c.SetFrequency(rigKHz, st.Direction); err != nil { if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err) applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
} else { } else {
@@ -10822,6 +11100,7 @@ func (a *App) SetUltrabeamDirection(direction int) error {
// current frequency with the new direction byte. If the antenna hasn't reported // current frequency with the new direction byte. If the antenna hasn't reported
// a frequency yet (just connected / link settling), fall back to the rig's CAT // a frequency yet (just connected / link settling), fall back to the rig's CAT
// frequency so the control still works. // frequency so the control still works.
a.noteMotorMoveCommanded()
st := a.motorAnt.Status() st := a.motorAnt.Status()
if st.Frequency <= 0 && a.cat != nil { if st.Frequency <= 0 && a.cat != nil {
if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 { if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 {
+81 -11
View File
@@ -68,6 +68,7 @@ import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder'; import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel'; import { AwardsPanel } from '@/components/AwardsPanel';
import { StatsPanel } from '@/components/StatsPanel'; import { StatsPanel } from '@/components/StatsPanel';
import { StationControlPanel } from '@/components/StationControlPanel';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid'; import { ClusterGrid } from '@/components/ClusterGrid';
@@ -608,7 +609,16 @@ export default function App() {
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' }); const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
const [matchCount, setMatchCount] = useState<number | null>(null); const [matchCount, setMatchCount] = useState<number | null>(null);
const [activeTab, setActiveTab] = useState('recent'); // The selected tab is remembered across restarts. Only the always-present tabs
// are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on
// a feature or CAT backend that isn't known this early, and restoring one that
// isn't there would show a blank pane.
const ALWAYS_TABS = ['main', 'recent', 'cluster', 'worked', 'awards', 'bandmap'];
const [activeTab, setActiveTab] = useState(() => {
const saved = localStorage.getItem('opslog.activeTab') || '';
return ALWAYS_TABS.includes(saved) ? saved : 'recent';
});
useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager. // QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
const [qslTabOpen, setQslTabOpen] = useState(false); const [qslTabOpen, setQslTabOpen] = useState(false);
const [qslDesignerOpen, setQslDesignerOpen] = useState(false); const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
@@ -623,6 +633,11 @@ export default function App() {
setStatsTabOpen(false); setStatsTabOpen(false);
setActiveTab((t) => (t === 'stats' ? 'recent' : t)); setActiveTab((t) => (t === 'stats' ? 'recent' : t));
} }
const [stationTabOpen, setStationTabOpen] = useState(false);
function closeStationTab() {
setStationTabOpen(false);
setActiveTab((t) => (t === 'station' ? 'recent' : t));
}
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller // Recent QSOs row cap, persisted. With AG Grid's virtual scroller
// huge logs render OK once loaded, but a 25k+ logbook still takes a // huge logs render OK once loaded, but a 25k+ logbook still takes a
// couple of seconds to round-trip from SQLite at launch. Defaulting // couple of seconds to round-trip from SQLite at launch. Defaulting
@@ -909,8 +924,15 @@ export default function App() {
// Ring buffer — only keep the last N spots; cluster firehose can be heavy. // Ring buffer — only keep the last N spots; cluster firehose can be heavy.
const [spots, setSpots] = useState<ClusterSpot[]>([]); const [spots, setSpots] = useState<ClusterSpot[]>([]);
const SPOTS_CAP = 1000; const SPOTS_CAP = 1000;
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>(''); // Cluster filter selections persist across restarts (writeUiPref → localStorage
const [clusterGroup, setClusterGroup] = useState(true); // + DB, so they also travel with a copied data/ folder). Loaders read the cache
// synchronously at first render; a single effect below writes them back.
const lsBool = (k: string, d: boolean) => { const v = localStorage.getItem(k); return v === null ? d : v === '1'; };
const lsSet = <T,>(k: string): Set<T> => { try { const a = JSON.parse(localStorage.getItem(k) || '[]'); return new Set(Array.isArray(a) ? a : []); } catch { return new Set<T>(); } };
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>(() => {
const v = localStorage.getItem('opslog.clusterFilterSource'); const n = v ? parseInt(v, 10) : NaN; return Number.isFinite(n) ? n : '';
});
const [clusterGroup, setClusterGroup] = useState(() => lsBool('opslog.clusterGroup', true));
const [clusterCmd, setClusterCmd] = useState(''); const [clusterCmd, setClusterCmd] = useState('');
// Cluster console: the raw traffic. Spots are parsed out of the stream into the // Cluster console: the raw traffic. Spots are parsed out of the stream into the
// grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded, // grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded,
@@ -938,22 +960,37 @@ export default function App() {
if (atBottom) el.scrollTop = el.scrollHeight; if (atBottom) el.scrollTop = el.scrollHeight;
}, [clusterLines]); }, [clusterLines]);
// Multi-band filter: empty set = all bands. The user toggles chips. // Multi-band filter: empty set = all bands. The user toggles chips.
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set()); const [clusterBands, setClusterBands] = useState<Set<string>>(() => lsSet<string>('opslog.clusterBands'));
// Lock-to-entry: when on, the band filter follows the entry's current // Lock-to-entry: when on, the band filter follows the entry's current
// band and the mode filter follows the entry's current mode. // band and the mode filter follows the entry's current mode.
const [clusterLockBand, setClusterLockBand] = useState(false); const [clusterLockBand, setClusterLockBand] = useState(() => lsBool('opslog.clusterLockBand', false));
const [clusterLockMode, setClusterLockMode] = useState(false); const [clusterLockMode, setClusterLockMode] = useState(() => lsBool('opslog.clusterLockMode', false));
// Status filter chips. Empty set = show every status (including // Status filter chips. Empty set = show every status (including
// already-worked). Otherwise only matching spots pass. // already-worked). Otherwise only matching spots pass.
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked'; type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set()); const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(() => lsSet<SpotStatusKey>('opslog.clusterStatusFilter'));
// Mode filter chips. Empty set = show every mode. Categories map the // Mode filter chips. Empty set = show every mode. Categories map the
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital). // inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
type SpotModeCat = 'SSB' | 'CW' | 'DATA'; type SpotModeCat = 'SSB' | 'CW' | 'DATA';
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set()); const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(() => lsSet<SpotModeCat>('opslog.clusterModeFilter'));
const [clusterSearch, setClusterSearch] = useState(''); const [clusterSearch, setClusterSearch] = useState(() => localStorage.getItem('opslog.clusterSearch') || '');
// Hide spots already worked (exact call worked, or this band+mode slot done). // Hide spots already worked (exact call worked, or this band+mode slot done).
const [clusterHideWorked, setClusterHideWorked] = useState(false); const [clusterHideWorked, setClusterHideWorked] = useState(() => lsBool('opslog.clusterHideWorked', false));
// Persist every cluster filter selection whenever it changes, so it is still
// set after a close/reopen.
useEffect(() => {
writeUiPref('opslog.clusterFilterSource', clusterFilterSource === '' ? '' : String(clusterFilterSource));
writeUiPref('opslog.clusterGroup', clusterGroup ? '1' : '0');
writeUiPref('opslog.clusterBands', JSON.stringify([...clusterBands]));
writeUiPref('opslog.clusterLockBand', clusterLockBand ? '1' : '0');
writeUiPref('opslog.clusterLockMode', clusterLockMode ? '1' : '0');
writeUiPref('opslog.clusterStatusFilter', JSON.stringify([...clusterStatusFilter]));
writeUiPref('opslog.clusterModeFilter', JSON.stringify([...clusterModeFilter]));
writeUiPref('opslog.clusterSearch', clusterSearch);
writeUiPref('opslog.clusterHideWorked', clusterHideWorked ? '1' : '0');
}, [clusterFilterSource, clusterGroup, clusterBands, clusterLockBand, clusterLockMode,
clusterStatusFilter, clusterModeFilter, clusterSearch, clusterHideWorked]);
// Bands shown side-by-side in the Band Map tab (portable). // Bands shown side-by-side in the Band Map tab (portable).
const [bandMapBands, setBandMapBands] = useState<string[]>(() => { const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; } try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
@@ -1756,6 +1793,12 @@ export default function App() {
const unsubRC = EventsOn('udp:remote_call', (raw: string) => { const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
}); });
// The DX Call was cleared in WSJT-X / JTDX / MSHV → clear our entry to match.
// Only when something is actually in the entry, so an idle digital app doesn't
// wipe a call being typed by hand.
const unsubClear = EventsOn('udp:clear_call', () => {
if (callsignRef.current?.value?.trim() || callsign.trim()) resetEntry();
});
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call // Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq). // (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
// An explicit click always wins over whatever call is currently in the field. // An explicit click always wins over whatever call is currently in the field.
@@ -1780,7 +1823,7 @@ export default function App() {
else setError('UDP auto-log: ' + msg); else setError('UDP auto-log: ' + msg);
} }
}); });
return () => { unsubDX?.(); unsubRC?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); }; return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -2495,6 +2538,7 @@ export default function App() {
{ name: 'tools', label: t('menu.tools'), items: [ { name: 'tools', label: t('menu.tools'), items: [
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' }, { type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' }, { type: 'item', label: t('stats.tab'), action: 'tools.stats' },
{ type: 'item', label: t('station.title'), action: 'tools.station' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' }, { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
@@ -2531,6 +2575,7 @@ export default function App() {
case 'edit.prefs': setShowSettings(true); break; case 'edit.prefs': setShowSettings(true); break;
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break; case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
case 'tools.qsldesigner': setQslDesignerOpen(true); break; case 'tools.qsldesigner': setQslDesignerOpen(true); break;
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.dvk': setDvkEnabled((v) => !v); break;
@@ -4155,6 +4200,21 @@ export default function App() {
</span> </span>
</TabsTrigger> </TabsTrigger>
)} )}
{stationTabOpen && (
<TabsTrigger value="station" className="gap-1.5">
{t('station.title')}
<span
role="button"
aria-label="Close Station Control"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeStationTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{qslTabOpen && ( {qslTabOpen && (
<TabsTrigger value="qsl" className="gap-1.5"> <TabsTrigger value="qsl" className="gap-1.5">
QSL Manager QSL Manager
@@ -4495,6 +4555,16 @@ export default function App() {
</TabsContent> </TabsContent>
)} )}
{stationTabOpen && (
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
<StationControlPanel
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
bearing={dxPath?.bearingShort ?? null}
/>
</TabsContent>
)}
{contestTabEnabled && ( {contestTabEnabled && (
<TabsContent value="contest" className="flex-1 min-h-0 p-0"> <TabsContent value="contest" className="flex-1 min-h-0 p-0">
<ContestPanel session={contest} onChange={updateContest} /> <ContestPanel session={contest} onChange={updateContest} />
@@ -49,6 +49,10 @@ const FIELDS: FieldDef[] = [
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true }, { id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' }, { id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' }, { id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
{ id: 'my_name', label: 'bulk.fMyName', group: 'My station', kind: 'text' },
{ id: 'my_arrl_sect', label: 'bulk.fMyArrlSect', group: 'My station', kind: 'text', upper: true },
{ id: 'my_darc_dok', label: 'bulk.fMyDarcDok', group: 'My station', kind: 'text', upper: true },
{ id: 'my_vucc_grids', label: 'bulk.fMyVuccGrids', group: 'My station', kind: 'text', upper: true },
// Contest // Contest
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true }, { id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' }, { id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
+65 -9
View File
@@ -203,6 +203,13 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 }, { group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 }, { group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 }, { group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
{ group: 'My station', label: t('rqg.c.my_name'), colId: 'my_name', headerName: t('rqg.c.my_name'), field: 'my_name' as any, width: 120 },
{ group: 'My station', label: t('rqg.c.my_wwff'), colId: 'my_wwff_ref', headerName: t('rqg.c.my_wwff'), field: 'my_wwff_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'My station', label: t('rqg.c.my_sig'), colId: 'my_sig', headerName: t('rqg.c.my_sig'), field: 'my_sig' as any, width: 90 },
{ group: 'My station', label: t('rqg.c.my_sig_info'), colId: 'my_sig_info', headerName: t('rqg.c.my_sig_info'), field: 'my_sig_info' as any, width: 120 },
{ group: 'My station', label: t('rqg.c.my_arrl_sect'), colId: 'my_arrl_sect', headerName: t('rqg.c.my_arrl_sect'), field: 'my_arrl_sect' as any, width: 90, cellClass: 'font-mono' },
{ group: 'My station', label: t('rqg.c.my_darc_dok'), colId: 'my_darc_dok', headerName: t('rqg.c.my_darc_dok'), field: 'my_darc_dok' as any, width: 90, cellClass: 'font-mono' },
{ group: 'My station', label: t('rqg.c.my_vucc_grids'),colId: 'my_vucc_grids', headerName: t('rqg.c.my_vucc_grids'), field: 'my_vucc_grids' as any, width: 130, cellClass: 'font-mono' },
// ── Misc ── // ── Misc ──
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true }, { group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
@@ -262,6 +269,30 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
// auto-save during that window. Set in the memo (runs at render, before the // auto-save during that window. Set in the memo (runs at render, before the
// column events) and cleared by the re-apply effect below. // column events) and cleared by the re-apply effect below.
const restoringRef = useRef(true); const restoringRef = useRef(true);
// Award-column visibility is stored as an explicit set of award CODES, NOT via
// AG Grid's column-state round-trip. Those columns load asynchronously and
// race the state restore, and AG Grid preserves an existing column's visibility
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly
// why previously-shown award columns kept vanishing on reopen. A dedicated set
// is deterministic: it drives `hide` directly and is re-enforced below.
const AWARD_SHOWN_KEY = 'hamlog.awardColsShown';
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
useEffect(() => {
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
if (loadLocal(AWARD_SHOWN_KEY)) return;
loadRemote(AWARD_SHOWN_KEY).then((remote) => {
if (remote && remote.length) {
seedLocal(AWARD_SHOWN_KEY, remote);
setAwardShown(new Set(remote.map((s: any) => String(s).toUpperCase())));
}
});
}, []);
const persistAwardShown = useCallback((next: Set<string>) => {
setAwardShown(next);
saveState(AWARD_SHOWN_KEY, [...next]);
}, []);
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => { const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
restoringRef.current = true; restoringRef.current = true;
const base = COL_CATALOG.map((c) => { const base = COL_CATALOG.map((c) => {
@@ -274,16 +305,26 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
headerTooltip: t('rqg.awardTip', { name: a.name }), headerTooltip: t('rqg.awardTip', { name: a.name }),
width: 110, width: 110,
cellClass: 'text-[11px]', cellClass: 'text-[11px]',
// Hidden by DEFAULT (award columns are opt-in). Without this, AG Grid shows // Visibility comes from the persisted award-code set, so a column the user
// them whenever the saved column state doesn't cover them — a newly-added // showed reappears on reopen and one they didn't stays hidden.
// award, an empty cache, or a rebuild that runs before the saved state is hide: !awardShown.has(a.code.toUpperCase()),
// re-applied — which is why "all award columns" kept reappearing. The user's
// saved state (a column they explicitly showed) still overrides this.
hide: true,
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '', valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
})); }));
return [...base, ...awards]; return [...base, ...awards];
}, [awardCols, COL_CATALOG, t]); }, [awardCols, COL_CATALOG, t, awardShown]);
// Enforce award-column visibility via the API after the columns (re)appear —
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
// so we set it explicitly whenever the award set or the loaded columns change.
useEffect(() => {
const api = gridRef.current?.api;
if (!api || !awardCols?.length) return;
for (const a of awardCols) {
const want = awardShown.has(a.code.toUpperCase());
const col = api.getColumn(`award_${a.code}`);
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
}
}, [awardCols, awardShown]);
const defaultColDef = useMemo<ColDef>(() => ({ const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, sortable: true,
@@ -342,10 +383,22 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
// state for "which columns are visible" — AG Grid's column state is the // state for "which columns are visible" — AG Grid's column state is the
// source of truth, and saveColumnState persists it. // source of truth, and saveColumnState persists it.
function isColVisible(colId: string): boolean { function isColVisible(colId: string): boolean {
// Award columns: the persisted set is the source of truth (see awardShown).
if (colId.startsWith('award_')) return awardShown.has(colId.slice('award_'.length).toUpperCase());
const col = gridRef.current?.api?.getColumn(colId); const col = gridRef.current?.api?.getColumn(colId);
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible; return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
} }
function setColVisible(colId: string, visible: boolean) { function setColVisible(colId: string, visible: boolean) {
// Award columns are driven by the persisted code set — update it and let the
// enforce effect apply visibility. This is what makes them stick across reopen.
if (colId.startsWith('award_')) {
const code = colId.slice('award_'.length).toUpperCase();
const next = new Set(awardShown);
if (visible) next.add(code); else next.delete(code);
persistAwardShown(next);
gridRef.current?.api?.setColumnsVisible([colId], visible);
return;
}
const api = gridRef.current?.api; const api = gridRef.current?.api;
if (!api) return; if (!api) return;
api.setColumnsVisible([colId], visible); api.setColumnsVisible([colId], visible);
@@ -373,6 +426,9 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
api.setColumnsVisible(visible, true); api.setColumnsVisible(visible, true);
api.setColumnsVisible(hidden, false); api.setColumnsVisible(hidden, false);
saveColumnState(); saveColumnState();
// Award columns are opt-in: reset hides them all.
persistAwardShown(new Set());
(awardCols ?? []).forEach((a) => api.setColumnsVisible([`award_${a.code}`], false));
} }
return ( return (
@@ -469,8 +525,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60"> <div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span> <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
<div className="flex gap-0.5"> <div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('rqg.all')}</button> <button className="text-[10px] text-primary hover:underline px-1" onClick={() => { persistAwardShown(new Set(awardCols.map((a) => a.code.toUpperCase()))); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], true)); }}>{t('rqg.all')}</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('rqg.none')}</button> <button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { persistAwardShown(new Set()); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], false)); }}>{t('rqg.none')}</button>
</div> </div>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
+60 -19
View File
@@ -259,7 +259,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
udp: 'UDP integrations', udp: 'UDP integrations',
awards: 'Awards', awards: 'Awards',
cat: 'CAT interface', cat: 'CAT interface',
rotator: 'PstRotator', rotator: 'Rotator',
winkeyer: 'CW Keyer', winkeyer: 'CW Keyer',
antenna: 'Ultrabeam / Steppir', antenna: 'Ultrabeam / Steppir',
antgenius: 'Antenna Genius', antgenius: 'Antenna Genius',
@@ -821,14 +821,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
digital_default: 'FT8', digital_default: 'FT8',
}); });
const [rotator, setRotator] = useState<RotatorSettings>({ const [rotator, setRotator] = useState<RotatorSettings>({
enabled: false, host: '127.0.0.1', port: 12000, has_elevation: false, enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
}); } as any);
const [rotatorTesting, setRotatorTesting] = useState(false); const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null); const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings. // Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number }>({ const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean }>({
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false,
}); });
const [ubTesting, setUbTesting] = useState(false); const [ubTesting, setUbTesting] = useState(false);
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null); const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -2245,7 +2245,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setRotatorTest(null); setRotatorTest(null);
try { try {
await TestRotator(rotator as any); await TestRotator(rotator as any);
setRotatorTest({ ok: true, msg: t('cat.rotatorOk') }); setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
} catch (e: any) { } catch (e: any) {
setRotatorTest({ ok: false, msg: String(e?.message ?? e) }); setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
} finally { } finally {
@@ -2373,6 +2373,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
)} )}
</div> </div>
<div className="border-t border-border/60 pt-3 space-y-1">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={ultrabeam.tx_inhibit} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, tx_inhibit: !!c }))} />
{t('hw.motorTxInhibit')}
</label>
<p className="text-xs text-muted-foreground pl-6">{t('hw.motorTxInhibitHint')}</p>
</div>
<div className="flex items-center gap-2 pt-2"> <div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || (isSerial ? !ultrabeam.com.trim() : !ultrabeam.host.trim())}> <Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || (isSerial ? !ultrabeam.com.trim() : !ultrabeam.host.trim())}>
{ubTesting ? t('hw.connecting') : t('hw.testConn')} {ubTesting ? t('hw.connecting') : t('hw.testConn')}
@@ -2467,41 +2474,73 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
} }
function RotatorPanel() { function RotatorPanel() {
const isRG = (rotator as any).type === 'rotgenius';
return ( return (
<> <>
<SectionHeader <SectionHeader
title="Rotator" title="Rotator"
hint={t('rot.hint')} hint={isRG ? undefined : t('rot.hint')}
/> />
<div className="space-y-4 max-w-xl"> <div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} /> <Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
Enable PstRotator control {t('rot.enable')}
</label> </label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('rot.type')}</Label>
{/* Switching to Rotator Genius moves the default port to its native
9006; back to PstRotator restores 12000. */}
<Select value={(rotator as any).type ?? 'pst'}
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : 12000 } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
</SelectContent>
</Select>
</div>
{isRG && (
<div className="space-y-1">
<Label>{t('rot.rotatorNum')}</Label>
<Select value={String((rotator as any).rotator_num ?? 1)}
onValueChange={(v) => setRotator((s) => ({ ...s, rotator_num: parseInt(v, 10) || 1 } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2"> <div className="space-y-1 col-span-2">
<Label>Host</Label> <Label>Host / IP</Label>
<Input <Input
value={rotator.host ?? ''} value={rotator.host ?? ''}
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))} onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
placeholder="127.0.0.1" placeholder={isRG ? '192.168.1.60' : '127.0.0.1'}
className="font-mono" className="font-mono"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>UDP port</Label> <Label>{isRG ? 'TCP port' : 'UDP port'}</Label>
<Input <Input
type="number" min={1} max={65535} type="number" min={1} max={65535}
value={rotator.port} value={rotator.port}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || 12000 }))} onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))}
className="font-mono" className="font-mono"
/> />
</div> </div>
</div> </div>
<label className="flex items-center gap-2 text-sm cursor-pointer"> {!isRG && (
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} /> <label className="flex items-center gap-2 text-sm cursor-pointer">
This rotator supports elevation (VHF / satellite) <Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
</label> This rotator supports elevation (VHF / satellite)
</label>
)}
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
<div className="flex items-center gap-2 pt-2"> <div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}> <Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')} {rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
@@ -2509,9 +2548,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}> <Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
Stop Stop
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}> {!isRG && (
Park <Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
</Button> Park
</Button>
)}
</div> </div>
{rotatorTest && ( {rotatorTest && (
<div className={cn( <div className={cn(
@@ -0,0 +1,329 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
import { RotorCompass } from '@/components/RotorCompass';
import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] };
type Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' };
function blankDevice(): Device {
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
}
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
// Station Control tab: a live compass you can click to turn, a heading readout, a
// GoTo box, quick N/E/S/W presets, and Stop. Heading is polled by the panel and
// passed in (so the panel can also order this widget); it drives the shared
// rotator backend.
function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: RotatorProps & {
hd: Heading; refetch: () => void; t: (k: string, v?: any) => string;
}) {
const [goto, setGoto] = useState('');
const [err, setErr] = useState('');
const turn = (az: number) => {
const a = ((Math.round(az) % 360) + 360) % 360;
RotatorGoTo(a, -1).then(refetch).catch((e) => setErr(String(e?.message ?? e)));
};
const poll = refetch;
const presets: [string, number][] = [['N', 0], ['E', 90], ['S', 180], ['W', 270]];
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Compass className="size-4 text-primary" />
<div className="text-sm font-semibold">{t('station.rotator')}</div>
<span className={cn('ml-auto size-2 rounded-full', hd.ok ? 'bg-success' : 'bg-warning')}
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
</div>
<div className="p-3 flex gap-4 items-start">
<RotorCompass
bearing={bearing ?? null}
headings={hd.ok ? [hd.azimuth] : []}
centerLat={centerLat ?? null}
centerLon={centerLon ?? null}
rotorEnabled={hd.ok}
onGoto={(az) => turn(az)}
/>
<div className="flex-1 min-w-0 space-y-2">
<div className="font-mono">
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
</div>
<div className="flex gap-1">
{presets.map(([lbl, az]) => (
<button key={lbl} type="button" onClick={() => turn(az)}
className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
{lbl}
</button>
))}
</div>
<div className="flex gap-1.5">
<Input value={goto} onChange={(e) => setGoto(e.target.value.replace(/[^0-9]/g, ''))}
onKeyDown={(e) => { if (e.key === 'Enter' && goto) turn(parseInt(goto, 10)); }}
placeholder="0359" className="h-8 flex-1 min-w-0 font-mono text-sm" />
<Button size="sm" className="h-8 shrink-0" disabled={!goto} onClick={() => turn(parseInt(goto, 10))}>{t('station.go')}</Button>
</div>
<Button size="sm" variant="outline" className="h-8 w-full" onClick={() => RotatorStop().then(poll).catch((e) => setErr(String(e?.message ?? e)))}>
<Square className="size-3 mr-1" /> {t('station.stop')}
</Button>
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
</div>
</div>
</div>
);
}
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
const { t } = useI18n();
const [devices, setDevices] = useState<Device[]>([]);
const [status, setStatus] = useState<Record<string, DevStatus>>({});
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
// Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
const [order, setOrder] = useState<string[]>(() => {
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
});
const dragId = useRef<string | null>(null);
const loadDevices = useCallback(async () => {
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
}, []);
const poll = useCallback(async () => {
try {
const s = ((await GetStationStatus()) ?? []) as DevStatus[];
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
} catch { /* ignore transient */ }
}, []);
const pollRot = useCallback(async () => {
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
}, []);
useEffect(() => { loadDevices(); }, [loadDevices]);
useEffect(() => {
poll(); pollRot();
const id = window.setInterval(() => { poll(); pollRot(); }, 3000);
return () => window.clearInterval(id);
}, [poll, pollRot, devices.length]);
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
// Reorder so `dragged` lands just before `target`.
const onDrop = (targetId: string) => {
const src = dragId.current; dragId.current = null;
if (!src || src === targetId) return;
const ids = widgetIds.filter((id) => id !== src);
const at = ids.indexOf(targetId);
ids.splice(at < 0 ? ids.length : at, 0, src);
persistOrder(ids);
};
const persist = async (next: Device[]) => {
setDevices(next);
try { await SaveStationDevices(next as any); } catch { /* surfaced by status */ }
await loadDevices();
poll();
};
const toggle = async (dev: Device, relay: number, on: boolean) => {
const key = `${dev.id}:${relay}`;
setBusy((b) => ({ ...b, [key]: true }));
// Optimistic flip so the switch feels instant; the poll reconciles.
setStatus((st) => {
const d = st[dev.id]; if (!d) return st;
return { ...st, [dev.id]: { ...d, relays: d.relays.map((r) => (r.number === relay ? { ...r, on } : r)) } };
});
try { await StationSetRelay(dev.id, relay, on); } catch { /* poll will correct */ }
await poll();
setBusy((b) => ({ ...b, [key]: false }));
};
const saveEdit = async () => {
if (!editing) return;
const d = { ...editing, name: editing.name.trim() || TYPE_LABEL[editing.type], host: editing.host.trim() };
const exists = devices.some((x) => x.id && x.id === d.id);
await persist(exists ? devices.map((x) => (x.id === d.id ? d : x)) : [...devices, d]);
setEditing(null);
};
const removeDevice = async (id: string) => { await persist(devices.filter((x) => x.id !== id)); };
// Build the widget list (rotator first by default, then devices), then order it
// by the saved drag order — unknown ids fall to the end in their natural order.
const deviceCard = (dev: Device) => {
const st = status[dev.id];
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<PlugZap className="size-4 text-primary" />
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
<div className="text-[10px] text-muted-foreground font-mono truncate">{TYPE_LABEL[dev.type]} · {dev.host}</div>
</div>
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
</div>
<div className="p-3 grid grid-cols-2 gap-2">
{relays.map((r) => {
const key = `${dev.id}:${r.number}`;
const label = r.label || `${t('station.relay')} ${r.number}`;
return (
<button key={r.number} type="button" disabled={!st?.connected}
onClick={() => toggle(dev, r.number, !r.on)}
className={cn('flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors disabled:opacity-40',
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
<span className={cn('flex items-center justify-center size-7 rounded-md shrink-0',
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
{busy[key] ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
</span>
<span className="min-w-0">
<span className="block text-xs font-medium truncate">{label}</span>
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
{r.on ? t('station.on') : t('station.off')}
</span>
</span>
</button>
);
})}
</div>
</div>
);
};
const widgets: { id: string; node: React.ReactNode }[] = [];
if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
}
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
const widgetIds = ordered.map((w) => w.id);
const noDevices = devices.length === 0 && !rot.enabled;
return (
<div className="flex-1 min-h-0 overflow-auto p-4">
<div className="flex items-center justify-between mb-3 max-w-4xl">
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
</Button>
</div>
{noDevices && !editing && (
<div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
{t('station.empty')}
</div>
)}
<div className="grid gap-3 max-w-4xl md:grid-cols-2 items-start">
{ordered.map((w) => (
<div key={w.id} draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }}
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}>
{w.node}
</div>
))}
</div>
{!noDevices && (
<p className="text-[11px] text-muted-foreground mt-2 max-w-4xl">{t('station.dragHint')}</p>
)}
{editing && (
<DeviceEditor device={editing} onChange={setEditing} onSave={saveEdit} onCancel={() => setEditing(null)} t={t} />
)}
</div>
);
}
function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
device: Device; onChange: (d: Device) => void; onSave: () => void; onCancel: () => void; t: (k: string, v?: any) => string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
const setType = (type: string) => {
const n = RELAY_COUNT[type] ?? 5;
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
onChange({ ...device, type, labels });
};
const isKM = device.type === 'kmtronic';
return (
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('station.type')}</Label>
<Select value={device.type} onValueChange={setType}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>{t('station.name')}</Label>
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
</div>
</div>
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
<Label>{t('station.host')}</Label>
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
</div>
{isKM && (
<>
<div className="space-y-1">
<Label>{t('station.user')}</Label>
<Input value={device.user ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, user: e.target.value })} />
</div>
<div className="space-y-1">
<Label>{t('station.pass')}</Label>
<Input type="password" value={device.pass ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, pass: e.target.value })} />
</div>
</>
)}
</div>
<div className="space-y-1">
<Label>{t('station.labels')}</Label>
<div className="grid grid-cols-4 gap-2">
{device.labels.map((lab, i) => (
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
onChange={(e) => { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
</div>
</div>
);
}
File diff suppressed because one or more lines are too long
+6
View File
@@ -26,6 +26,12 @@ const PORTABLE_KEYS = [
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing 'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane) 'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
'opslog.mapBasemap', // world map basemap (light / street / satellite) 'opslog.mapBasemap', // world map basemap (light / street / satellite)
// Cluster filter selections — restored on reopen.
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.activeTab', // last selected tab
'hamlog.awardColsShown', // which award columns are shown in the QSO grid
]; ];
// syncPortablePrefs reconciles the DB with the local cache at startup: // syncPortablePrefs reconciles the DB with the local cache at startup:
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.19.7'; export const APP_VERSION = '0.19.8';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+8
View File
@@ -386,8 +386,12 @@ export function GetSolarData():Promise<solar.Data>;
export function GetStartupStatus():Promise<main.StartupStatus>; export function GetStartupStatus():Promise<main.StartupStatus>;
export function GetStationDevices():Promise<Array<main.StationDevice>>;
export function GetStationSettings():Promise<main.StationSettings>; export function GetStationSettings():Promise<main.StationSettings>;
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
export function GetTelemetryEnabled():Promise<boolean>; export function GetTelemetryEnabled():Promise<boolean>;
export function GetUIPref(arg1:string):Promise<string>; export function GetUIPref(arg1:string):Promise<string>;
@@ -718,6 +722,8 @@ export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>; export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>; export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>; export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
@@ -766,6 +772,8 @@ export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function StartCWDecoder():Promise<void>; export function StartCWDecoder():Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
export function StopCWDecoder():Promise<void>; export function StopCWDecoder():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>; export function SwitchCATRig(arg1:number):Promise<void>;
+16
View File
@@ -730,10 +730,18 @@ export function GetStartupStatus() {
return window['go']['main']['App']['GetStartupStatus'](); return window['go']['main']['App']['GetStartupStatus']();
} }
export function GetStationDevices() {
return window['go']['main']['App']['GetStationDevices']();
}
export function GetStationSettings() { export function GetStationSettings() {
return window['go']['main']['App']['GetStationSettings'](); return window['go']['main']['App']['GetStationSettings']();
} }
export function GetStationStatus() {
return window['go']['main']['App']['GetStationStatus']();
}
export function GetTelemetryEnabled() { export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled'](); return window['go']['main']['App']['GetTelemetryEnabled']();
} }
@@ -1394,6 +1402,10 @@ export function SaveRotatorSettings(arg1) {
return window['go']['main']['App']['SaveRotatorSettings'](arg1); return window['go']['main']['App']['SaveRotatorSettings'](arg1);
} }
export function SaveStationDevices(arg1) {
return window['go']['main']['App']['SaveStationDevices'](arg1);
}
export function SaveStationSettings(arg1) { export function SaveStationSettings(arg1) {
return window['go']['main']['App']['SaveStationSettings'](arg1); return window['go']['main']['App']['SaveStationSettings'](arg1);
} }
@@ -1490,6 +1502,10 @@ export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder'](); return window['go']['main']['App']['StartCWDecoder']();
} }
export function StationSetRelay(arg1, arg2, arg3) {
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
}
export function StopCWDecoder() { export function StopCWDecoder() {
return window['go']['main']['App']['StopCWDecoder'](); return window['go']['main']['App']['StopCWDecoder']();
} }
+87
View File
@@ -2333,9 +2333,11 @@ export namespace main {
} }
export class RotatorSettings { export class RotatorSettings {
enabled: boolean; enabled: boolean;
type: string;
host: string; host: string;
port: number; port: number;
has_elevation: boolean; has_elevation: boolean;
rotator_num: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new RotatorSettings(source); return new RotatorSettings(source);
@@ -2344,9 +2346,11 @@ export namespace main {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.type = source["type"];
this.host = source["host"]; this.host = source["host"];
this.port = source["port"]; this.port = source["port"];
this.has_elevation = source["has_elevation"]; this.has_elevation = source["has_elevation"];
this.rotator_num = source["rotator_num"];
} }
} }
export class SecretStatus { export class SecretStatus {
@@ -2419,6 +2423,86 @@ export namespace main {
this.db_path = source["db_path"]; this.db_path = source["db_path"];
} }
} }
export class StationDevice {
id: string;
type: string;
name: string;
host: string;
user?: string;
pass?: string;
labels: string[];
static createFrom(source: any = {}) {
return new StationDevice(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.type = source["type"];
this.name = source["name"];
this.host = source["host"];
this.user = source["user"];
this.pass = source["pass"];
this.labels = source["labels"];
}
}
export class StationRelay {
number: number;
label: string;
on: boolean;
static createFrom(source: any = {}) {
return new StationRelay(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.number = source["number"];
this.label = source["label"];
this.on = source["on"];
}
}
export class StationDeviceStatus {
id: string;
name: string;
type: string;
connected: boolean;
error?: string;
relays: StationRelay[];
static createFrom(source: any = {}) {
return new StationDeviceStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.type = source["type"];
this.connected = source["connected"];
this.error = source["error"];
this.relays = this.convertValues(source["relays"], StationRelay);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class StationInfoComputed { export class StationInfoComputed {
country: string; country: string;
dxcc: number; dxcc: number;
@@ -2441,6 +2525,7 @@ export namespace main {
this.lon = source["lon"]; this.lon = source["lon"];
} }
} }
export class StationSettings { export class StationSettings {
callsign: string; callsign: string;
operator: string; operator: string;
@@ -2473,6 +2558,7 @@ export namespace main {
baud: number; baud: number;
follow: boolean; follow: boolean;
step_khz: number; step_khz: number;
tx_inhibit: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new UltrabeamSettings(source); return new UltrabeamSettings(source);
@@ -2489,6 +2575,7 @@ export namespace main {
this.baud = source["baud"]; this.baud = source["baud"];
this.follow = source["follow"]; this.follow = source["follow"];
this.step_khz = source["step_khz"]; this.step_khz = source["step_khz"];
this.tx_inhibit = source["tx_inhibit"];
} }
} }
export class UltrabeamStatusInfo { export class UltrabeamStatusInfo {
+1
View File
@@ -352,6 +352,7 @@ type FlexController interface {
SetRFPower(int) error SetRFPower(int) error
SetTunePower(int) error SetTunePower(int) error
SetTune(bool) error SetTune(bool) error
SetTXInhibit(bool) error
SetVOX(bool) error SetVOX(bool) error
SetVOXLevel(int) error SetVOXLevel(int) error
SetVOXDelay(int) error SetVOXDelay(int) error
+18
View File
@@ -109,6 +109,7 @@ type flexTX struct {
tunePower int tunePower int
tune bool tune bool
transmitting bool // interlock state == TRANSMITTING transmitting bool // interlock state == TRANSMITTING
inhibit bool // transmit inhibited (e.g. while a motorized antenna moves)
voxEnable bool voxEnable bool
voxLevel int voxLevel int
voxDelay int voxDelay int
@@ -1708,6 +1709,23 @@ func (f *Flex) SetTune(on bool) error {
return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on }) return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on })
} }
// SetTXInhibit blocks (on=true) or allows transmission at the radio. Used to keep
// the operator from keying while a motorized antenna's elements are moving —
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
// footswitch or an external keyer, which a software PTT block could not.
//
// It also labels the interlock reason "OpsLog" so SmartSDR shows WHY TX is
// blocked. The reason is best-effort (a separate interlock object); the inhibit
// is what actually blocks TX, so a rig that ignores the reason still stays safe.
func (f *Flex) SetTXInhibit(on bool) error {
if on {
f.send("interlock set reason=OpsLog")
} else {
f.send("interlock set reason=")
}
return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
}
func (f *Flex) SetVOX(on bool) error { func (f *Flex) SetVOX(on bool) error {
return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on }) return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on })
} }
+15 -1
View File
@@ -286,19 +286,33 @@ func (n *icomNet) ctrlPump() {
switch icnLE.Uint16(buf[4:]) { switch icnLE.Uint16(buf[4:]) {
case 0x07: // ping case 0x07: // ping
_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote)) _, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
case 0x00: // idle keepalive from the rig — nothing to do
case 0x01: // retransmit request — resend from the CONTROL sent-buffer case 0x01: // retransmit request — resend from the CONTROL sent-buffer
if k >= 8 { if k >= 8 {
n.ctrlResend(icnLE.Uint16(buf[6:])) n.ctrlResend(icnLE.Uint16(buf[6:]))
} }
case 0x05: // rig-initiated disconnect — it dropped US case 0x05: // rig-initiated disconnect — it dropped US
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig") debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
default:
// Anything else on the control stream is (almost always) the rig's
// reply to our token renewal. Log it: a 0x40-length token packet
// carries a result code, and if the rig is REJECTING renewals this is
// where the ~2-3 min disconnect originates. The hex makes the cause
// visible in the friend's log without a protocol analyzer.
if k >= 0x18 {
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:0x18])
} else {
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:k])
}
} }
} }
if time.Since(lastIdle) > 100*time.Millisecond { if time.Since(lastIdle) > 100*time.Millisecond {
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote)) _, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
lastIdle = time.Now() lastIdle = time.Now()
} }
if time.Since(lastToken) > 45*time.Second { // Renew well inside the rig's ~2-min token timeout. 30 s (was 45) leaves room
// for one lost renewal + its retransmit before the token would lapse.
if time.Since(lastToken) > 30*time.Second {
n.renewToken() n.renewToken()
lastToken = time.Now() lastToken = time.Now()
} }
+1
View File
@@ -283,6 +283,7 @@ func (b *IcomSerial) ReadState() (RigState, error) {
b.dspMu.Unlock() b.dspMu.Unlock()
return s, nil return s, nil
} }
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
return RigState{}, err // control link dead → let the Manager reconnect return RigState{}, err // control link dead → let the Manager reconnect
} }
// USB (no liveness signal): the rig briefly stops answering CI-V while it // USB (no liveness signal): the rig briefly stops answering CI-V while it
+21 -3
View File
@@ -52,6 +52,11 @@ type Event struct {
DecodeFreqHz int64 // RF frequency (dial + audio offset) DecodeFreqHz int64 // RF frequency (dial + audio offset)
DecodeSNR int // reported SNR (dB) DecodeSNR int // reported SNR (dB)
DecodeCQ bool // the decode was a CQ DecodeCQ bool // the decode was a CQ
// ClearCall is set when a WSJT/JTDX/MSHV Status message reports an EMPTY DX
// Call after previously reporting one — i.e. the operator cleared the call in
// the digital app. OpsLog clears its entry to match.
ClearCall bool
} }
// Server is a single inbound UDP listener. // Server is a single inbound UDP listener.
@@ -64,7 +69,8 @@ type Server struct {
stopped bool stopped bool
mu sync.Mutex mu sync.Mutex
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
} }
func newServer(cfg Config, out chan<- Event) *Server { func newServer(cfg Config, out chan<- Event) *Server {
@@ -213,6 +219,17 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.Mode = w.Mode ev.Mode = w.Mode
ev.FreqHz = w.FreqHz ev.FreqHz = w.FreqHz
ev.LoggedADIF = w.LoggedADIF ev.LoggedADIF = w.LoggedADIF
// A Status with an empty DX Call, right after one that had a call, means the
// operator cleared it in WSJT-X / JTDX / MSHV. Fire ONE clear (tracked per
// server) — an idle app sends empty Status every second, and we must not
// re-clear (which would fight a manual entry) on each of those.
s.mu.Lock()
prev := s.lastDX
s.lastDX = w.DXCall
s.mu.Unlock()
if w.DXCall == "" && prev != "" {
ev.ClearCall = true
}
case ServiceADIF: case ServiceADIF:
// JTAlert / GridTracker forward a text ADIF record after a QSO is // JTAlert / GridTracker forward a text ADIF record after a QSO is
// logged. Guard against keep-alive / non-ADIF chatter on the socket: // logged. Guard against keep-alive / non-ADIF chatter on the socket:
@@ -268,8 +285,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
default: default:
return return
} }
// Empty events are useless; skip. // Empty events are useless; skip — EXCEPT a clear signal, which is meant to be
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" { // empty (the DX Call was cleared in the digital app).
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall {
return return
} }
select { select {
+4
View File
@@ -724,6 +724,10 @@ var bulkEditableCols = map[string]bool{
"my_antenna": true, "my_antenna": true,
"my_sig": true, "my_sig": true,
"my_sig_info": true, "my_sig_info": true,
"my_name": true,
"my_arrl_sect": true,
"my_darc_dok": true,
"my_vucc_grids": true,
// Contest — the exchange/label fields that are constant across a run. // Contest — the exchange/label fields that are constant across a run.
// (srx/stx serial numbers stay excluded: they are per-QSO.) // (srx/stx serial numbers stay excluded: they are per-QSO.)
"contest_id": true, "contest_id": true,
+168
View File
@@ -0,0 +1,168 @@
// Package relaydev drives network relay boards used for station control (power
// sequencing, switching accessories). Two devices are supported, both over HTTP:
//
// - WebSwitch 1216H — 5 relays. Control: GET /relaycontrol/{on|off}/{n};
// status: GET /relaystate/get2/1$2$3$4$5$ → lines "n,state".
// (Protocol taken from the operator's own working ShackMaster driver.)
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
//
// A Device presents the same surface to the app regardless of wire protocol.
package relaydev
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// Device is one relay board.
type Device interface {
Count() int // number of user-controllable relays
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
Set(ctx context.Context, relay int, on bool) error // relay is 1-based
}
func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }
// get issues a GET with optional basic auth and returns the body on 2xx.
func get(ctx context.Context, url, user, pass string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if user != "" || pass != "" {
req.SetBasicAuth(user, pass)
}
resp, err := httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
// ── WebSwitch 1216H ────────────────────────────────────────────────────
type webswitch struct {
host string
count int
}
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
func (w *webswitch) Count() int { return w.count }
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > w.count {
return fmt.Errorf("relay %d out of range 1..%d", relay, w.count)
}
action := "off"
if on {
action = "on"
}
_, err := get(ctx, fmt.Sprintf("http://%s/relaycontrol/%s/%d", w.host, action, relay), "", "")
return err
}
func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
// Build the "1$2$3$..." selector the device expects.
var sel strings.Builder
for i := 1; i <= w.count; i++ {
sel.WriteString(strconv.Itoa(i))
sel.WriteByte('$')
}
body, err := get(ctx, fmt.Sprintf("http://%s/relaystate/get2/%s", w.host, sel.String()), "", "")
if err != nil {
return nil, err
}
out := make([]bool, w.count)
// Lines "n,state" — "1,1", "2,0", …
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
parts := strings.Split(strings.TrimSpace(line), ",")
if len(parts) != 2 {
continue
}
n, e1 := strconv.Atoi(parts[0])
st, e2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if e1 != nil || e2 != nil || n < 1 || n > w.count {
continue
}
out[n-1] = st == 1
}
return out, nil
}
// ── KMTronic LAN 8-relay WEB ───────────────────────────────────────────
type kmtronic struct {
host string
user, pass string
count int
}
// NewKMTronic builds a KMTronic LAN WEB relay client (8 relays). user/pass are
// blank unless the board's HTTP authentication is enabled.
func NewKMTronic(host, user, pass string) Device {
return &kmtronic{host: host, user: user, pass: pass, count: 8}
}
func (k *kmtronic) Count() int { return k.count }
func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > k.count {
return fmt.Errorf("relay %d out of range 1..%d", relay, k.count)
}
state := "00"
if on {
state = "01"
}
// FF<rr><ss>: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off.
_, err := get(ctx, fmt.Sprintf("http://%s/FF%02d%s", k.host, relay, state), k.user, k.pass)
return err
}
// kmStatus mirrors status.xml. relay0 is reserved; relay1..relay8 are the board.
type kmStatus struct {
XMLName xml.Name `xml:"response"`
Relays []struct {
XMLName xml.Name
Value string `xml:",chardata"`
} `xml:",any"`
}
func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
body, err := get(ctx, fmt.Sprintf("http://%s/status.xml", k.host), k.user, k.pass)
if err != nil {
return nil, err
}
var s kmStatus
if err := xml.Unmarshal(body, &s); err != nil {
return nil, fmt.Errorf("kmtronic: bad status.xml: %w", err)
}
out := make([]bool, k.count)
for _, r := range s.Relays {
// Element names are relay0..relay8; relay0 is reserved.
name := r.XMLName.Local
if !strings.HasPrefix(name, "relay") {
continue
}
n, e := strconv.Atoi(strings.TrimPrefix(name, "relay"))
if e != nil || n < 1 || n > k.count {
continue
}
out[n-1] = strings.TrimSpace(r.Value) == "1"
}
return out, nil
}
+101
View File
@@ -0,0 +1,101 @@
package relaydev
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The status parsers are the risky part; pin them against the documented wire
// formats using a stub HTTP server.
func TestWebswitchStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/relaystate/get2/") {
t.Errorf("unexpected status path %q", r.URL.Path)
}
_, _ = w.Write([]byte("1,1\n2,0\n3,1\n4,0\n5,1\n"))
}))
defer srv.Close()
d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://"))
st, err := d.Status(context.Background())
if err != nil {
t.Fatal(err)
}
want := []bool{true, false, true, false, true}
if len(st) != 5 {
t.Fatalf("got %d relays, want 5", len(st))
}
for i := range want {
if st[i] != want[i] {
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
}
}
}
func TestWebswitchSetURL(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
}))
defer srv.Close()
d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://"))
if err := d.Set(context.Background(), 4, true); err != nil {
t.Fatal(err)
}
if gotPath != "/relaycontrol/on/4" {
t.Errorf("set path = %q, want /relaycontrol/on/4", gotPath)
}
_ = d.Set(context.Background(), 4, false)
}
func TestKMTronicStatus(t *testing.T) {
// relay0 reserved (ignored); relay7 + relay8 ON.
xmlBody := `<?xml version="1.0"?><response>` +
`<relay0>0</relay0><relay1>0</relay1><relay2>0</relay2><relay3>0</relay3>` +
`<relay4>0</relay4><relay5>0</relay5><relay6>0</relay6><relay7>1</relay7><relay8>1</relay8>` +
`</response>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/status.xml" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(xmlBody))
}))
defer srv.Close()
d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "")
st, err := d.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if len(st) != 8 {
t.Fatalf("got %d relays, want 8", len(st))
}
if st[6] != true || st[7] != true {
t.Errorf("relay7/8 = %v/%v, want on/on", st[6], st[7])
}
for i := 0; i < 6; i++ {
if st[i] {
t.Errorf("relay %d unexpectedly on", i+1)
}
}
}
func TestKMTronicSetURL(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
}))
defer srv.Close()
d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "")
if err := d.Set(context.Background(), 8, true); err != nil {
t.Fatal(err)
}
if gotPath != "/FF0801" {
t.Errorf("set path = %q, want /FF0801", gotPath)
}
_ = d.Set(context.Background(), 1, false) // → /FF0100
}
+193
View File
@@ -0,0 +1,193 @@
// Package rotgenius drives a 4O3A Rotator Genius over its native TCP text
// protocol (rev 4, default port 9006). All data is fixed-length extended-ASCII;
// there is no sequence/framing wrapper — you send a short command and read back a
// fixed-length reply.
//
// Commands used here:
//
// |h read heading + full state (both rotators)
// |A<rot><az3> move rotator <rot> ('1'|'2') to azimuth az3 (000..360)
// |P<rot> / |M<rot> rotate CW / CCW
// |S stop all movement
//
// The |h reply is 72 bytes: "|h" + Active[1] + Panic[1] then, per rotator,
// CurrentAzimuth[3] LimitCW[3] LimitCCW[3] Config[1] Moving[1] Offset[4]
// TargetAzimuth[3] StartAzimuth[3] Limit[1] Name[12]. Numeric fields may be
// space-padded; a CurrentAzimuth of 999 means the sensor is not connected.
package rotgenius
import (
"fmt"
"net"
"strconv"
"strings"
"time"
)
const (
defaultPort = 9006
dialTimeout = 4 * time.Second
ioTimeout = 4 * time.Second
hdrReplyLen = 72 // fixed length of the |h reply
)
// Status is one rotator's live state parsed from a |h reply.
type Status struct {
Azimuth int // current heading in degrees (0..360)
Connected bool // false when the sensor reports 999 (not connected)
Moving int // 0 not moving, 1 CW, 2 CCW
Target int // target azimuth when moving (else -1)
}
// Client is a stateless connector: each call opens a short-lived TCP connection,
// mirroring how the PstRotator client works, so there is no socket to manage.
type Client struct {
host string
port int
}
func New(host string, port int) *Client {
if port <= 0 {
port = defaultPort
}
return &Client{host: host, port: port}
}
func (c *Client) dial() (net.Conn, error) {
d := net.Dialer{Timeout: dialTimeout}
conn, err := d.Dial("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)))
if err != nil {
return nil, err
}
_ = conn.SetDeadline(time.Now().Add(ioTimeout))
return conn, nil
}
// exchange sends cmd and returns up to max bytes of the reply.
func (c *Client) exchange(cmd string, max int) ([]byte, error) {
conn, err := c.dial()
if err != nil {
return nil, err
}
defer conn.Close()
if _, err := conn.Write([]byte(cmd)); err != nil {
return nil, fmt.Errorf("write %q: %w", cmd, err)
}
buf := make([]byte, 0, max)
tmp := make([]byte, max)
for len(buf) < max {
n, rerr := conn.Read(tmp)
if n > 0 {
buf = append(buf, tmp[:n]...)
}
if rerr != nil {
break // deadline or EOF — return what we have and let the parser judge
}
}
return buf, nil
}
// atoiField trims the space-padding a Rotator Genius field may carry and parses
// it. An empty or non-numeric field yields 0.
func atoiField(s string) int {
n, _ := strconv.Atoi(strings.TrimSpace(s))
return n
}
// Heading reads the current azimuth of the given rotator (1 or 2). raw is the
// decoded field for diagnostics.
func (c *Client) Heading(rotator int) (Status, string, error) {
st, err := c.Read(rotator)
if err != nil {
return Status{}, "", err
}
return st, strconv.Itoa(st.Azimuth), nil
}
// Read fetches and parses the full |h reply for one rotator (1 or 2).
func (c *Client) Read(rotator int) (Status, error) {
if rotator != 1 && rotator != 2 {
rotator = 1
}
reply, err := c.exchange("|h", hdrReplyLen)
if err != nil {
return Status{}, err
}
i := strings.Index(string(reply), "|h")
if i < 0 || len(reply)-i < hdrReplyLen {
return Status{}, fmt.Errorf("rotgenius: short |h reply (%d bytes)", len(reply))
}
p := reply[i:]
// Per-rotator block base: rotator 1 at offset 4, rotator 2 at 4+34=38.
base := 4
if rotator == 2 {
base = 38
}
// Within a rotator block: CurrentAzimuth@0, LimitCW@3, LimitCCW@6, Config@9,
// Moving@10, Offset@11, TargetAzimuth@15, StartAzimuth@18, Limit@21, Name@22.
cur := atoiField(string(p[base : base+3]))
moving := atoiField(string(p[base+10 : base+11]))
target := atoiField(string(p[base+15 : base+18]))
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
if target != 999 {
st.Target = target
}
return st, nil
}
// GoTo moves the rotator to az (0..360). The reply's status byte is 'K' on
// accept, 'F' on reject.
func (c *Client) GoTo(rotator, az int) error {
if rotator != 1 && rotator != 2 {
rotator = 1
}
if az < 0 {
az = 0
}
if az > 360 {
az = 360
}
reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
if err != nil {
return err
}
return checkKF(reply, "GoTo")
}
// Stop halts all movement.
func (c *Client) Stop() error {
reply, err := c.exchange("|S", 8)
if err != nil {
return err
}
return checkKF(reply, "Stop")
}
// CW / CCW nudge a rotator; it runs to its limit unless stopped.
func (c *Client) CW(rotator int) error { return c.rotate('P', rotator) }
func (c *Client) CCW(rotator int) error { return c.rotate('M', rotator) }
func (c *Client) rotate(cmd byte, rotator int) error {
if rotator != 1 && rotator != 2 {
rotator = 1
}
reply, err := c.exchange(fmt.Sprintf("|%c%d", cmd, rotator), 8)
if err != nil {
return err
}
return checkKF(reply, string(cmd))
}
// checkKF reads the accept/reject status: 'K' ok, 'F' failed. The reply carries
// no other letters (the rest is the header + digits), so scanning for them is
// unambiguous.
func checkKF(reply []byte, what string) error {
s := string(reply)
if strings.ContainsRune(s, 'K') {
return nil
}
if strings.ContainsRune(s, 'F') {
return fmt.Errorf("rotgenius: %s rejected by the controller", what)
}
return fmt.Errorf("rotgenius: no reply to %s", what)
}
+127
View File
@@ -0,0 +1,127 @@
package rotgenius
import "testing"
// Build a 72-byte |h reply from per-rotator field values, so the fixed offsets in
// Read() are pinned to the rev-4 layout. Numeric fields are space/zero padded to
// their documented widths.
func buildHReply(cur1, cw1, ccw1 string, cfg1, mv1 byte, off1, tgt1, start1 string, lim1 byte, name1,
cur2, cw2, ccw2 string, cfg2, mv2 byte, off2, tgt2, start2 string, lim2 byte, name2 string) []byte {
pad := func(s string, n int) string {
for len(s) < n {
s = " " + s
}
return s[:n]
}
b := []byte("|h")
b = append(b, '0', 0x00) // Active, Panic
block := func(cur, cw, ccw string, cfg, mv byte, off, tgt, start string, lim byte, name string) {
b = append(b, []byte(pad(cur, 3))...)
b = append(b, []byte(pad(cw, 3))...)
b = append(b, []byte(pad(ccw, 3))...)
b = append(b, cfg, mv)
b = append(b, []byte(pad(off, 4))...)
b = append(b, []byte(pad(tgt, 3))...)
b = append(b, []byte(pad(start, 3))...)
b = append(b, lim)
b = append(b, []byte(pad(name, 12))...)
}
block(cur1, cw1, ccw1, cfg1, mv1, off1, tgt1, start1, lim1, name1)
block(cur2, cw2, ccw2, cfg2, mv2, off2, tgt2, start2, lim2, name2)
return b
}
func TestReadParsesBothRotators(t *testing.T) {
// Rotator 1: az 100, moving CW (1), no target (999). Rotator 2: az 999 (sensor
// offline), not moving. Mirrors the manual's worked example.
reply := buildHReply(
"100", "005", "350", 'A', '1', "0", "999", "999", '0', "TOW1",
"999", "010", "060", 'E', '0', "1", "999", "999", '0', "")
c := &Client{}
_ = c
if len(reply) != hdrReplyLen {
t.Fatalf("built reply is %d bytes, want %d — field widths drifted from rev 4", len(reply), hdrReplyLen)
}
st1, err := parseFor(reply, 1)
if err != nil {
t.Fatal(err)
}
if st1.Azimuth != 100 || !st1.Connected || st1.Moving != 1 {
t.Errorf("rotator 1 = %+v, want az 100, connected, moving CW", st1)
}
if st1.Target != -1 {
t.Errorf("rotator 1 target = %d, want -1 (999 = not set)", st1.Target)
}
st2, err := parseFor(reply, 2)
if err != nil {
t.Fatal(err)
}
if st2.Connected || st2.Azimuth != 999 {
t.Errorf("rotator 2 = %+v, want disconnected (az 999)", st2)
}
}
// parseFor exercises the offset math without a socket.
func parseFor(reply []byte, rotator int) (Status, error) {
base := 4
if rotator == 2 {
base = 38
}
if len(reply) < hdrReplyLen {
return Status{}, errShort
}
cur := atoiField(string(reply[base : base+3]))
moving := atoiField(string(reply[base+10 : base+11]))
target := atoiField(string(reply[base+15 : base+18]))
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
if target != 999 {
st.Target = target
}
return st, nil
}
var errShort = fmtErrorf("short")
func fmtErrorf(s string) error { return &strErr{s} }
type strErr struct{ s string }
func (e *strErr) Error() string { return e.s }
func TestGoToFormatting(t *testing.T) {
// The command must zero-pad the azimuth to 3 digits, per the manual's fields.
cases := map[int]string{0: "|A1000", 5: "|A1005", 90: "|A1090", 360: "|A1360"}
for az, want := range cases {
got := "|A" + "1" + pad3(az)
if got != want {
t.Errorf("az %d → %q, want %q", az, got, want)
}
}
}
func pad3(az int) string {
s := ""
switch {
case az >= 100:
s = itoa(az)
case az >= 10:
s = "0" + itoa(az)
default:
s = "00" + itoa(az)
}
return s
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
+33 -23
View File
@@ -74,6 +74,12 @@ type Client struct {
connMu sync.Mutex connMu sync.Mutex
conn io.ReadWriteCloser conn io.ReadWriteCloser
// ioMu serialises EVERY exchange on the shared connection — a status query
// (write "?A" then read 11 bytes) and a command write must never interleave,
// or their bytes mix on the wire and both frames are corrupted. The status
// poll runs on one goroutine, tuning on another, so this is essential.
ioMu sync.Mutex
statusMu sync.RWMutex statusMu sync.RWMutex
lastStatus *Status lastStatus *Status
lastSetKHz int lastSetKHz int
@@ -85,11 +91,6 @@ type Client struct {
pendingDirAt time.Time pendingDirAt time.Time
pendingDirSet bool pendingDirSet bool
// After a Home/Retract the controller drops out of AUTOTRACK and ignores
// frequency sets until it is turned back ON. Set on Retract, cleared by
// re-enabling on the next SetFrequency.
needAutotrack bool
stopChan chan struct{} stopChan chan struct{}
running bool running bool
} }
@@ -231,6 +232,8 @@ func (c *Client) queryStatus() (*Status, error) {
if conn == nil { if conn == nil {
return nil, fmt.Errorf("steppir: not connected") return nil, fmt.Errorf("steppir: not connected")
} }
c.ioMu.Lock()
defer c.ioMu.Unlock()
setDeadline(conn, 3*time.Second) setDeadline(conn, 3*time.Second)
if _, err := conn.Write([]byte("?A\r")); err != nil { if _, err := conn.Write([]byte("?A\r")); err != nil {
return nil, fmt.Errorf("write status cmd: %w", err) return nil, fmt.Errorf("write status cmd: %w", err)
@@ -250,10 +253,14 @@ func parseStatus(b []byte) (*Status, error) {
freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10 freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10
active := b[6] active := b[6]
dir := decodeDir(b[7]) dir := decodeDir(b[7])
// active==0xFF means "command just received" (not motion); the 0x01 bit is // active-motors byte: one bit per element that is currently moving.
// documented as always set. Treat anything else non-zero as motors busy. // 0x04 driver · 0x08 DIR1 · 0x10 reflector · 0x20 DIR2 (mask 0x3C)
// Bit 0 (0x01) is documented as always set — not a motor. 0xFF means the
// controller just received a command, not motion. So "moving" is precisely
// "any real motor bit set", ignoring the always-on bit and the ack value.
const motorBits = 0x3C
moving := 0 moving := 0
if active != 0xFF && (active & ^byte(0x01)) != 0 { if active != 0xFF && active&motorBits != 0 {
moving = 1 moving = 1
} }
return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil
@@ -299,6 +306,9 @@ func (c *Client) writeCmd(pkt []byte) error {
if conn == nil { if conn == nil {
return fmt.Errorf("steppir: not connected") return fmt.Errorf("steppir: not connected")
} }
c.ioMu.Lock()
defer c.ioMu.Unlock()
log.Printf("steppir: → % X", pkt)
setDeadline(conn, 3*time.Second) setDeadline(conn, 3*time.Second)
if _, err := conn.Write(pkt); err != nil { if _, err := conn.Write(pkt); err != nil {
c.closeConn() c.closeConn()
@@ -309,16 +319,19 @@ func (c *Client) writeCmd(pkt []byte) error {
return nil return nil
} }
// SetFrequency tunes the elements to freqKhz with the given direction. If a prior // SetFrequency tunes the elements to freqKhz with the given direction.
// Retract dropped AUTOTRACK, re-enable it first — otherwise the set is ignored. //
// AUTOTRACK is (re-)enabled first, EVERY time: the controller ignores frequency
// sets unless it is in AUTOTRACK mode ("when not in AUTOTRACK only CALIBRATE and
// RETRACT work"), and it can be out of AUTOTRACK at power-on, after a Home, or if
// switched off on the front panel. Sending the 'R' command each tune is cheap and
// makes tuning work regardless of the controller's current mode — which is what
// was silently failing before.
func (c *Client) SetFrequency(freqKhz int, direction int) error { func (c *Client) SetFrequency(freqKhz int, direction int) error {
if c.needAutotrack { if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil { // AUTOTRACK ON
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil { return err
return err
}
c.needAutotrack = false
} }
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil { if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil { // set freq + dir
return err return err
} }
c.statusMu.Lock() c.statusMu.Lock()
@@ -343,8 +356,9 @@ func (c *Client) SetDirection(direction int) error {
return c.SetFrequency(khz, direction) return c.SetFrequency(khz, direction)
} }
// Retract homes the elements into the hubs (storage). This leaves AUTOTRACK off, // Retract homes the elements into the hubs (storage). This drops the controller
// so the next SetFrequency re-enables it. // out of AUTOTRACK, but that is handled transparently: the next SetFrequency
// re-issues AUTOTRACK ON before tuning.
func (c *Client) Retract() error { func (c *Client) Retract() error {
// A valid frequency must accompany the command; reuse the last one. // A valid frequency must accompany the command; reuse the last one.
khz := c.LastSetKHz() khz := c.LastSetKHz()
@@ -355,9 +369,5 @@ func (c *Client) Retract() error {
khz = 14000 // any in-range value; the controller just homes khz = 14000 // any in-range value; the controller just homes
} }
} }
if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'S')); err != nil { return c.writeCmd(buildSet(khz*1000, DirNormal, 'S'))
return err
}
c.needAutotrack = true
return nil
} }
+24 -2
View File
@@ -50,10 +50,32 @@ func TestBuildSetDirectionAndCommand(t *testing.T) {
} }
} }
// A REAL status frame captured off an SDA controller (F4BPO's, 2026-07-15):
// 40 41 00 4C C5 84 00 87 30 38 0D — 50.313 MHz, idle, bidirectional, interface
// version "08". Using the actual device output (rather than hand-built bytes)
// pins parseStatus to real hardware, and independently confirms the ÷10 wire
// scale: 0x4CC584 × 10 = 50 313 000 Hz, a genuine 6 m frequency.
func TestParseStatusRealFrame(t *testing.T) {
frame := []byte{0x40, 0x41, 0x00, 0x4C, 0xC5, 0x84, 0x00, 0x87, 0x30, 0x38, 0x0D}
st, err := parseStatus(frame)
if err != nil {
t.Fatal(err)
}
if st.Frequency != 50313 {
t.Errorf("freq = %d kHz, want 50313 (50.313 MHz)", st.Frequency)
}
if st.Direction != DirBi { // 0x87 & 0xE0 = 0x80 = bidirectional
t.Errorf("direction = %d, want %d (bidirectional)", st.Direction, DirBi)
}
if st.MotorsMoving != 0 {
t.Errorf("moving = %d, want 0 (motors byte 0x00)", st.MotorsMoving)
}
}
// parseStatus decodes what the controller sends back — the inverse of buildSet's // parseStatus decodes what the controller sends back — the inverse of buildSet's
// frequency field, plus the direction nibble. // frequency field, plus the direction nibble and the motors-busy byte.
func TestParseStatus(t *testing.T) { func TestParseStatus(t *testing.T) {
frame := []byte{0x00, 0x00, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '1', '2', 0x0D} frame := []byte{0x40, 0x41, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '0', '8', 0x0D}
st, err := parseStatus(frame) st, err := parseStatus(frame)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.19.7" appVersion = "0.19.8"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.