feat: While closing OpsLog will keep the same size and position for next launch

This commit is contained in:
2026-07-15 22:03:42 +02:00
parent d354709939
commit 5b96f53930
7 changed files with 1053 additions and 271 deletions
+373 -102
View File
@@ -16,6 +16,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"hamlog/internal/adif"
@@ -38,11 +39,11 @@ import (
"hamlog/internal/extsvc"
"hamlog/internal/integrations/udp"
"hamlog/internal/lookup"
"hamlog/internal/lotwusers"
"hamlog/internal/netctl"
"hamlog/internal/offlineq"
"hamlog/internal/operating"
"hamlog/internal/pota"
"hamlog/internal/lotwusers"
"hamlog/internal/offlineq"
"hamlog/internal/powergenius"
"hamlog/internal/profile"
"hamlog/internal/qslcard"
@@ -50,6 +51,7 @@ import (
"hamlog/internal/rotator/pst"
"hamlog/internal/settings"
"hamlog/internal/solar"
"hamlog/internal/steppir"
"hamlog/internal/ultrabeam"
"hamlog/internal/winkeyer"
@@ -153,12 +155,19 @@ const (
keyRotatorPort = "rotator.port"
keyRotatorHasElevation = "rotator.has_elevation"
// Ultrabeam antenna (TCP, e.g. via an RS232↔Ethernet adapter) — Hardware → Antenna.
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
// "ultrabeam." prefix for backward compatibility with configs saved before the
// SteppIR support; keyMotorType / keyMotorTransport default to the old Ultrabeam
// TCP behaviour when absent, so an existing install keeps working untouched.
keyUltrabeamEnabled = "ultrabeam.enabled"
keyUltrabeamHost = "ultrabeam.host"
keyUltrabeamPort = "ultrabeam.port"
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600)
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
// port is fixed at 9007, so only the IP is configurable.
@@ -413,6 +422,14 @@ type App struct {
cat *cat.Manager
dxcc *dxcc.Manager
cluster *cluster.Manager
// Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a
// spot (DXCC/POTA), emitting it to the UI, running alert rules — which can hit
// a remote MySQL via isWorkedBandMode — and mirroring it to the Flex all used
// to run inline in the read loop, so a single slow step stopped draining the
// TCP socket and the whole feed fell behind the node (visible as the grid
// lagging telnet). The read loop now just enqueues here; one worker does the work.
clusterEventCh chan clusterEvent
clusterDropped int64 // spots/lines dropped when the queue was full (atomic)
pota *pota.Cache
awardRefs *awardref.Repo
qslTemplates *qslcard.Repo
@@ -422,7 +439,7 @@ type App struct {
extsvc *extsvc.Manager
winkeyer *winkeyer.Manager
clublog *clublog.Manager
ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
@@ -478,6 +495,7 @@ type App struct {
// blocking the window close; the subsequent programmatic Quit() call
// must be allowed through.
shuttingDown bool
compact bool // window is in the compact one-row mode (skip saving its tiny size as the normal geometry)
// Cached operator location used to compute distance/bearing for
// cluster spots. Refreshed on profile activation; zero means
@@ -862,52 +880,17 @@ func (a *App) startup(ctx context.Context) {
// with country + continent via cty.dat BEFORE emitting it, so the UI
// renders the row with all metadata already filled (no flicker of
// empty Country / Cont columns while the batch status fetch runs).
// Cluster events are processed OFF the socket-read goroutine (see clusterEvent /
// clusterEventWorker). Sized large so ordinary traffic and even an SH/DX/100
// burst never fill it; a full queue drops-and-counts rather than block the read
// loop, which was the actual cause of the feed falling behind telnet.
a.clusterEventCh = make(chan clusterEvent, 8192)
go a.clusterEventWorker()
a.cluster = cluster.NewManager(
func(s cluster.Spot) {
if a.dxcc != nil {
if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil {
s.Country = m.Entity.Name
s.Continent = m.Continent
s.CQZone = m.CQZone
s.ITUZone = m.ITUZone
if a.opSet && (m.Lat != 0 || m.Lon != 0) {
s.DistanceKm = int(haversineKm(a.opLat, a.opLon, m.Lat, m.Lon) + 0.5)
sp := initialBearingDeg(a.opLat, a.opLon, m.Lat, m.Lon)
s.ShortPath = int(sp + 0.5)
s.LongPath = (s.ShortPath + 180) % 360
}
}
}
// POTA: tag the spot when the DX station is currently activating a park.
if a.pota != nil {
if info, ok := a.pota.Lookup(s.DXCall); ok {
s.POTARef = info.Reference
s.POTAName = info.ParkName
}
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:spot", s)
}
// A HISTORICAL spot (recovered from a SH/DX table) goes to the grid and
// stops there. It is a replay of the past: firing 100 alerts at once, or
// painting 100 stale stations on the panadapter as if they were on the
// air right now, would be actively misleading.
if s.Historical {
return
}
// Fire any matching alert rules (sound / visual / e-mail).
a.evaluateAlerts(s)
// Mirror the spot onto the FlexRadio panadapter when enabled. The
// Color is left to the backend default for now — status-based
// colouring can be filled in here later (new entity / worked / …).
if a.catFlexSpots && a.cat != nil {
a.cat.SendSpot(cat.SpotInfo{
FreqHz: s.FreqHz,
Callsign: s.DXCall,
Comment: s.Comment,
})
}
},
// onSpot / onLine run on the session's socket-read goroutine, so they must
// do nothing slow: just enqueue and return, keeping the TCP socket drained.
func(s cluster.Spot) { a.enqueueClusterEvent(clusterEvent{spot: &s}) },
func() {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status())
@@ -916,11 +899,7 @@ func (a *App) startup(ctx context.Context) {
// Raw traffic → the cluster console. Spots are parsed out of this stream,
// but SH/DX, WHO, the MOTD and error replies are NOT spots — without this
// they were dropped on the floor and the command box looked inert.
func(l cluster.Line) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:line", l)
}
},
func(l cluster.Line) { a.enqueueClusterEvent(clusterEvent{line: &l}) },
)
a.refreshOperatorGrid()
if cs, _ := a.clusterAutoConnect(); cs {
@@ -1031,6 +1010,9 @@ func (a *App) startup(ctx context.Context) {
go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL
go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence
// Reopen where the window was last left (size + position).
a.restoreWindowState()
fmt.Println("OpsLog: db ready at", a.dbPath)
}
@@ -1063,6 +1045,10 @@ func (a *App) beforeClose(ctx context.Context) bool {
}
a.shuttingDown = true
// Capture geometry now, before any shutdown UI can resize the window, so the
// next launch reopens exactly here.
a.saveWindowState()
steps := a.plannedShutdownSteps()
if len(steps) == 0 {
// Nothing to do — exit immediately, no need to flash a modal.
@@ -1311,6 +1297,83 @@ type dbPointer struct {
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
// ── Window geometry (window.json) ──────────────────────────────────────
//
// Remembered across restarts so the window reopens where and how you left it.
// Kept in its OWN local file, NOT the database: the DB may be a MySQL server
// shared by several machines, and each has its own screens — persisting geometry
// there would make two installs fight over one position.
type windowState struct {
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Maximised bool `json:"maximised"`
}
func windowStatePath(dataDir string) string { return filepath.Join(dataDir, "window.json") }
func readWindowState(dataDir string) (windowState, bool) {
var w windowState
b, err := os.ReadFile(windowStatePath(dataDir))
if err != nil {
return w, false
}
if json.Unmarshal(b, &w) != nil {
return w, false
}
return w, true
}
func writeWindowState(dataDir string, w windowState) {
if b, err := json.MarshalIndent(w, "", " "); err == nil {
_ = os.WriteFile(windowStatePath(dataDir), b, 0o644)
}
}
// saveWindowState captures the current geometry. Called as the window closes.
// While in compact mode the window is pinned to a tiny fixed size, so we keep the
// previously-saved normal geometry rather than overwrite it with 1240×158 — only
// the maximised flag is meaningful to update there.
func (a *App) saveWindowState() {
if a.ctx == nil || a.dataDir == "" {
return
}
prev, _ := readWindowState(a.dataDir)
max := wruntime.WindowIsMaximised(a.ctx)
ws := prev
ws.Maximised = max
if !max && !a.compact {
w, h := wruntime.WindowGetSize(a.ctx)
x, y := wruntime.WindowGetPosition(a.ctx)
if w > 0 && h > 0 {
ws.Width, ws.Height, ws.X, ws.Y = w, h, x, y
}
}
writeWindowState(a.dataDir, ws)
}
// restoreWindowState reopens the window where it was last left. The window is
// CREATED maximised (WindowStartState in main.go), which is also the first-run
// default — so we only act when a windowed geometry was saved, leaving the
// maximised majority untouched and flash-free.
func (a *App) restoreWindowState() {
if a.ctx == nil {
return
}
ws, ok := readWindowState(a.dataDir)
if !ok || ws.Maximised {
return // first run, or last closed maximised — the default already covers it
}
// Guard against a corrupt / absurd size that would open an unusable window.
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
return
}
wruntime.WindowUnmaximise(a.ctx)
wruntime.WindowSetSize(a.ctx, ws.Width, ws.Height)
wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y)
}
// readBootstrap returns the full bootstrap config (DB path + MySQL), or a zero
// value if the file is missing/unreadable.
func readBootstrap(dataDir string) dbPointer {
@@ -4674,6 +4737,7 @@ func (a *App) SetCompactMode(on bool) {
if a.ctx == nil {
return
}
a.compact = on
if on {
// Lock the window to the compact size by pinning min == max. Without
// the max pin, dragging the frameless window (esp. across monitors /
@@ -5755,6 +5819,91 @@ func (a *App) SetAlertEmailTo(addr string) error {
// match (visual/sound via a frontend event, e-mail via SMTP). Cheap when no
// rules exist; the worked-before check only runs for rules that opt into it and
// already matched, so it's off the hot path.
// clusterEvent is one item off the cluster feed — a parsed spot OR a raw console
// line. A single union type on one channel preserves the order lines and spots
// arrived in (the console shows a line, then its parsed spot), which two separate
// channels could not guarantee.
type clusterEvent struct {
spot *cluster.Spot
line *cluster.Line
}
// enqueueClusterEvent hands an event to the worker WITHOUT blocking. It runs on
// the cluster session's socket-read goroutine: blocking here would stop draining
// the TCP socket, the node's send buffer would fill, and the feed would fall
// behind — the exact bug this indirection fixes. If the queue is full (processing
// can't keep up), drop and count rather than stall the whole feed.
func (a *App) enqueueClusterEvent(ev clusterEvent) {
select {
case a.clusterEventCh <- ev:
default:
n := atomic.AddInt64(&a.clusterDropped, 1)
if n == 1 || n%100 == 0 {
applog.Printf("cluster: processing backlog — dropped %d event(s); the feed is faster than enrichment/UI", n)
}
}
}
// clusterEventWorker drains clusterEventCh and does everything that used to run
// inline in the socket-read callback: enrich a spot (DXCC/POTA/distance), emit it
// to the UI, run alert rules (which may query a remote MySQL) and mirror it to the
// Flex panadapter — all serialised on this one goroutine, off the read path.
func (a *App) clusterEventWorker() {
for ev := range a.clusterEventCh {
if ev.line != nil {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:line", *ev.line)
}
continue
}
if ev.spot == nil {
continue
}
s := *ev.spot
if a.dxcc != nil {
if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil {
s.Country = m.Entity.Name
s.Continent = m.Continent
s.CQZone = m.CQZone
s.ITUZone = m.ITUZone
if a.opSet && (m.Lat != 0 || m.Lon != 0) {
s.DistanceKm = int(haversineKm(a.opLat, a.opLon, m.Lat, m.Lon) + 0.5)
sp := initialBearingDeg(a.opLat, a.opLon, m.Lat, m.Lon)
s.ShortPath = int(sp + 0.5)
s.LongPath = (s.ShortPath + 180) % 360
}
}
}
// POTA: tag the spot when the DX station is currently activating a park.
if a.pota != nil {
if info, ok := a.pota.Lookup(s.DXCall); ok {
s.POTARef = info.Reference
s.POTAName = info.ParkName
}
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:spot", s)
}
// A HISTORICAL spot (recovered from a SH/DX table) goes to the grid and
// stops there. It is a replay of the past: firing 100 alerts at once, or
// painting 100 stale stations on the panadapter as if they were on the air
// right now, would be actively misleading.
if s.Historical {
continue
}
// Fire any matching alert rules (sound / visual / e-mail).
a.evaluateAlerts(s)
// Mirror the spot onto the FlexRadio panadapter when enabled.
if a.catFlexSpots && a.cat != nil {
a.cat.SendSpot(cat.SpotInfo{
FreqHz: s.FreqHz,
Callsign: s.DXCall,
Comment: s.Comment,
})
}
}
}
func (a *App) evaluateAlerts(s cluster.Spot) {
if a.alertStore == nil {
return
@@ -9043,7 +9192,7 @@ func (a *App) SetCATFrequency(hz int64) error {
// honouring the same enabled/follow/in-range/step-deadband rules as the follow
// loop. Called from SetCATFrequency so a spot click moves the antenna instantly.
func (a *App) ultrabeamFollowNow(freqHz int64) {
c := a.ultrabeam
c := a.motorAnt
if c == nil || freqHz <= 0 {
return
}
@@ -9051,13 +9200,13 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
if err != nil || !s.Enabled || !s.Follow {
return
}
applog.Printf("ultrabeam: followNow — deliberate CAT set to %.3f MHz", float64(freqHz)/1e6)
applog.Printf("motor-antenna: followNow — deliberate CAT set to %.3f MHz", float64(freqHz)/1e6)
step := s.StepKHz
if step <= 0 {
step = 50
}
st, err := c.GetStatus()
if err != nil || st == nil || !st.Connected {
st := c.Status()
if !st.Connected {
return
}
if st.FreqMin > 0 && st.FreqMax > 0 {
@@ -10357,32 +10506,109 @@ func boolStr(b bool) string {
return "0"
}
// ── Ultrabeam antenna (TCP) ────────────────────────────────────────────
// ── Motorized antenna (Ultrabeam / SteppIR) ────────────────────────────
// UltrabeamSettings is the JSON shape for the Hardware → Antenna panel.
// motorAntenna is the shared control surface of a motorized antenna. The
// Ultrabeam and SteppIR clients differ only in wire protocol; everything the app
// does — poll, follow the rig, switch pattern, retract — is the same, so it runs
// against this interface and neither the follow loop nor the UI cares which is on
// the other end.
type motorStatus struct {
Connected bool
Direction int // 0 normal, 1 180°, 2 bidirectional
Frequency int // kHz
Band int
Moving bool
FreqMin int // MHz, 0 = unknown (SteppIR does not report a tunable range)
FreqMax int // MHz, 0 = unknown
}
type motorAntenna interface {
Start() error
Stop()
SetFrequency(khz, dir int) error
SetDirection(dir int) error
Retract() error
LastSetKHz() int
Status() motorStatus
}
// ubAdapter / steppirAdapter wrap each concrete client to the shared interface,
// translating only the status shape (both already use the same 0/1/2 direction
// convention, so commands pass straight through).
type ubAdapter struct{ c *ultrabeam.Client }
func (a ubAdapter) Start() error { return a.c.Start() }
func (a ubAdapter) Stop() { a.c.Stop() }
func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
func (a ubAdapter) Retract() error { return a.c.Retract() }
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
func (a ubAdapter) Status() motorStatus {
st, err := a.c.GetStatus()
if err != nil || st == nil {
return motorStatus{}
}
return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0, FreqMin: st.FreqMin, FreqMax: st.FreqMax}
}
type steppirAdapter struct{ c *steppir.Client }
func (a steppirAdapter) Start() error { return a.c.Start() }
func (a steppirAdapter) Stop() { a.c.Stop() }
func (a steppirAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
func (a steppirAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
func (a steppirAdapter) Retract() error { return a.c.Retract() }
func (a steppirAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
func (a steppirAdapter) Status() motorStatus {
st, err := a.c.GetStatus()
if err != nil || st == nil {
return motorStatus{}
}
return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0}
}
// UltrabeamSettings is the JSON shape for the Hardware → Antenna panel. The name
// is kept (bindings + frontend) though it now covers SteppIR too.
type UltrabeamSettings struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
Type string `json:"type"` // "ultrabeam" | "steppir"
Transport string `json:"transport"` // "tcp" | "serial"
Host string `json:"host"` // tcp
Port int `json:"port"` // tcp
COM string `json:"com"` // serial device
Baud int `json:"baud"` // serial baud
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)
}
// GetUltrabeamSettings returns the persisted Ultrabeam config with defaults.
// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
// to the pre-SteppIR behaviour (Ultrabeam over TCP) so an existing install is
// unchanged.
func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
out := UltrabeamSettings{Port: 23, StepKHz: 50}
out := UltrabeamSettings{Type: "ultrabeam", Transport: "tcp", Port: 23, Baud: 9600, StepKHz: 50}
if a.settings == nil {
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)
if err != nil {
return out, err
}
out.Enabled = m[keyUltrabeamEnabled] == "1"
if t := m[keyMotorType]; t == "steppir" || t == "ultrabeam" {
out.Type = t
}
if tr := m[keyMotorTransport]; tr == "serial" || tr == "tcp" {
out.Transport = tr
}
out.Host = m[keyUltrabeamHost]
if p, _ := strconv.Atoi(m[keyUltrabeamPort]); p > 0 && p <= 65535 {
out.Port = p
}
out.COM = m[keyMotorCOM]
if b, _ := strconv.Atoi(m[keyMotorBaud]); b >= 1200 && b <= 115200 {
out.Baud = b
}
out.Follow = m[keyUltrabeamFollow] == "1"
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
out.StepKHz = st
@@ -10402,12 +10628,25 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
if s.StepKHz != 25 && s.StepKHz != 50 && s.StepKHz != 100 {
s.StepKHz = 50
}
if s.Type != "steppir" {
s.Type = "ultrabeam"
}
if s.Transport != "serial" {
s.Transport = "tcp"
}
if s.Baud < 1200 || s.Baud > 115200 {
s.Baud = 9600
}
for k, v := range map[string]string{
keyUltrabeamEnabled: boolStr(s.Enabled),
keyUltrabeamHost: strings.TrimSpace(s.Host),
keyUltrabeamPort: strconv.Itoa(s.Port),
keyUltrabeamFollow: boolStr(s.Follow),
keyUltrabeamStep: strconv.Itoa(s.StepKHz),
keyMotorType: s.Type,
keyMotorTransport: s.Transport,
keyMotorCOM: strings.TrimSpace(s.COM),
keyMotorBaud: strconv.Itoa(s.Baud),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -10417,6 +10656,27 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
return nil
}
// newMotorClient builds the concrete client for the configured antenna type and
// transport, wrapped in the shared interface. Returns nil if nothing usable is
// configured (no host for TCP, no COM for serial).
func newMotorClient(s UltrabeamSettings) motorAntenna {
if s.Type == "steppir" {
tr := steppir.Transport{Mode: s.Transport, Host: strings.TrimSpace(s.Host), Port: s.Port, COM: strings.TrimSpace(s.COM), Baud: s.Baud}
if tr.Mode == "serial" && tr.COM == "" {
return nil
}
if tr.Mode != "serial" && tr.Host == "" {
return nil
}
return steppirAdapter{steppir.New(tr)}
}
// Ultrabeam is TCP only.
if strings.TrimSpace(s.Host) == "" {
return nil
}
return ubAdapter{ultrabeam.New(s.Host, s.Port)}
}
// startUltrabeam stops any existing client and starts a fresh one if the
// antenna is enabled and configured. Safe to call repeatedly (on startup and
// after a settings save).
@@ -10426,22 +10686,26 @@ func (a *App) startUltrabeam() {
close(a.ubFollowStop)
a.ubFollowStop = nil
}
if a.ultrabeam != nil {
if a.motorAnt != nil {
// Background teardown so saving Settings doesn't block on an in-progress
// connect (Stop waits for the dial timeout).
go a.ultrabeam.Stop()
a.ultrabeam = nil
go a.motorAnt.Stop()
a.motorAnt = nil
}
s, err := a.GetUltrabeamSettings()
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
if err != nil || !s.Enabled {
return
}
a.ultrabeam = ultrabeam.New(s.Host, s.Port)
_ = a.ultrabeam.Start()
c := newMotorClient(s)
if c == nil {
return // not configured (missing host/COM)
}
a.motorAnt = c
_ = a.motorAnt.Start()
if s.Follow {
stop := make(chan struct{})
a.ubFollowStop = stop
go a.ultrabeamFollowLoop(a.ultrabeam, s.StepKHz, stop)
go a.ultrabeamFollowLoop(a.motorAnt, s.StepKHz, stop)
}
}
@@ -10449,7 +10713,7 @@ func (a *App) startUltrabeam() {
// 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
// until stop is closed (a settings change or shutdown).
func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan struct{}) {
func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struct{}) {
if stepKHz <= 0 {
stepKHz = 50
}
@@ -10468,8 +10732,8 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan
if !rs.Connected || rs.FreqHz <= 0 {
continue
}
st, err := c.GetStatus()
if err != nil || st == nil || !st.Connected {
st := c.Status()
if !st.Connected {
continue
}
rigKHz := int(rs.FreqHz / 1000)
@@ -10529,74 +10793,81 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
out := UltrabeamStatusInfo{}
s, _ := a.GetUltrabeamSettings()
out.Enabled = s.Enabled
if a.ultrabeam == nil {
return out
}
st, err := a.ultrabeam.GetStatus()
if err != nil || st == nil {
if a.motorAnt == nil {
return out
}
st := a.motorAnt.Status()
out.Connected = st.Connected
out.Direction = st.Direction
out.Frequency = st.Frequency
out.Band = st.Band
out.Moving = st.MotorsMoving != 0
out.Moving = st.Moving
return out
}
// SetUltrabeamDirection switches the antenna pattern: 0=normal, 1=180°,
// 2=bidirectional (re-issues the current frequency with the new direction).
func (a *App) SetUltrabeamDirection(direction int) error {
if a.ultrabeam == nil {
return fmt.Errorf("Ultrabeam not connected — enable it in Settings → Antenna")
if a.motorAnt == nil {
return fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
}
if direction < 0 || direction > 2 {
return fmt.Errorf("invalid direction %d", direction)
}
// The device has no standalone direction command: it re-issues the current
// frequency with the new direction byte. If the antenna hasn't reported a
// frequency yet (just connected / remote link still settling), fall back to
// the rig's current CAT frequency so the control still works.
st, _ := a.ultrabeam.GetStatus()
if (st == nil || st.Frequency <= 0) && a.cat != nil {
// Neither controller has a standalone direction command: both re-issue the
// 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
// frequency so the control still works.
st := a.motorAnt.Status()
if st.Frequency <= 0 && a.cat != nil {
if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 {
return a.ultrabeam.SetFrequency(int(rs.FreqHz/1000), direction)
return a.motorAnt.SetFrequency(int(rs.FreqHz/1000), direction)
}
}
return a.ultrabeam.SetDirection(direction)
return a.motorAnt.SetDirection(direction)
}
// UltrabeamRetract retracts all elements (storage / safe position).
func (a *App) UltrabeamRetract() error {
if a.ultrabeam == nil {
return fmt.Errorf("Ultrabeam not connected")
if a.motorAnt == nil {
return fmt.Errorf("antenna not connected")
}
return a.ultrabeam.Retract()
return a.motorAnt.Retract()
}
// TestUltrabeam opens a one-shot TCP connection and reads one status frame to
// verify host/port without disturbing the running poller.
// TestUltrabeam opens a one-shot connection and reads one status frame to verify
// the transport (TCP host/port or serial COM) without disturbing the running poller.
func (a *App) TestUltrabeam(s UltrabeamSettings) error {
if strings.TrimSpace(s.Host) == "" {
if s.Transport == "serial" {
if strings.TrimSpace(s.COM) == "" {
return fmt.Errorf("serial port required")
}
} else if strings.TrimSpace(s.Host) == "" {
return fmt.Errorf("host required")
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = 23
}
c := ultrabeam.New(s.Host, s.Port)
c := newMotorClient(s)
if c == nil {
return fmt.Errorf("antenna not configured")
}
if err := c.Start(); err != nil {
return err
}
defer c.Stop()
// The poller connects + reads status on its 2s tick; give it a couple of
// cycles to come up, then check we got a live status frame.
deadline := time.Now().Add(6 * time.Second)
// The poller connects + reads status on its tick; give it a few cycles to come
// up, then check we got a live status frame.
deadline := time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
if st, err := c.GetStatus(); err == nil && st != nil && st.Connected {
if st := c.Status(); st.Connected {
return nil
}
}
if s.Transport == "serial" {
return fmt.Errorf("no response on %s", s.COM)
}
return fmt.Errorf("no response from %s:%d", s.Host, s.Port)
}
+2 -2
View File
@@ -3503,10 +3503,10 @@ export default function App() {
);
})()}
{/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
{/* Motorized-antenna pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
{ubStatus.enabled && (
<div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam pattern') : 'Ultrabeam: connecting…'}>
title={ubStatus.connected ? (ubStatus.moving ? 'Antenna: moving…' : 'Antenna pattern') : 'Antenna: connecting…'}>
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
</button>
+63 -7
View File
@@ -261,7 +261,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
cat: 'CAT interface',
rotator: 'PstRotator',
winkeyer: 'CW Keyer',
antenna: 'UltraBeam',
antenna: 'Ultrabeam / Steppir',
antgenius: 'Antenna Genius',
pgxl: 'Power Genius',
flex: 'FlexRadio',
@@ -826,9 +826,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Ultrabeam antenna (TCP) settings.
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number; follow: boolean; step_khz: number }>({
enabled: false, host: '', port: 23, follow: false, step_khz: 50,
// 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 }>({
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50,
});
const [ubTesting, setUbTesting] = useState(false);
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -2267,16 +2267,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
function UltrabeamPanel() {
const isSteppir = ultrabeam.type === 'steppir';
const isSerial = isSteppir && ultrabeam.transport === 'serial';
return (
<>
<SectionHeader
title={t('hw.ultrabeam')}
title={t('hw.motorAntenna')}
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
Enable Ultrabeam control
{t('hw.motorEnable')}
</label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('hw.motorType')}</Label>
{/* Ultrabeam is TCP only; picking it forces the transport back to TCP
so the serial fields never apply to it. */}
<Select value={ultrabeam.type ?? 'ultrabeam'}
onValueChange={(v) => setUltrabeam((s) => ({ ...s, type: v, transport: v === 'ultrabeam' ? 'tcp' : s.transport }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ultrabeam">Ultrabeam</SelectItem>
<SelectItem value="steppir">SteppIR</SelectItem>
</SelectContent>
</Select>
</div>
{isSteppir && (
<div className="space-y-1">
<Label>{t('hw.motorTransport')}</Label>
<Select value={ultrabeam.transport ?? 'tcp'}
onValueChange={(v) => setUltrabeam((s) => ({ ...s, transport: v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tcp">{t('hw.motorTcp')}</SelectItem>
<SelectItem value="serial">{t('hw.motorSerial')}</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{isSerial ? (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>{t('hw.motorCom')}</Label>
<Input
value={ultrabeam.com ?? ''}
onChange={(e) => setUltrabeam((s) => ({ ...s, com: e.target.value }))}
placeholder="COM3"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>{t('hw.motorBaud')}</Label>
<Select value={String(ultrabeam.baud || 9600)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, baud: parseInt(v, 10) || 9600 }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{[1200, 4800, 9600, 19200].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
) : (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
@@ -2297,6 +2349,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
/>
</div>
</div>
)}
{isSteppir && (
<p className="text-xs text-muted-foreground">{t('hw.steppirHint')}</p>
)}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
@@ -2318,7 +2374,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)}
</div>
<div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || !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')}
</Button>
</div>
+4 -4
View File
@@ -91,7 +91,7 @@ const en: Dict = {
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'CW Keyer',
'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
// General panel
'gen.hint': 'App behaviour (saved instantly).',
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
@@ -165,7 +165,7 @@ const en: Dict = {
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first).',
'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
// CAT panel body
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
@@ -355,7 +355,7 @@ const fr: Dict = {
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'Manipulateur CW',
'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
@@ -420,7 +420,7 @@ const fr: Dict = {
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 12 min pour corriger un QSO mal saisi avant).",
'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
+8
View File
@@ -2465,8 +2465,12 @@ export namespace main {
}
export class UltrabeamSettings {
enabled: boolean;
type: string;
transport: string;
host: string;
port: number;
com: string;
baud: number;
follow: boolean;
step_khz: number;
@@ -2477,8 +2481,12 @@ export namespace main {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.type = source["type"];
this.transport = source["transport"];
this.host = source["host"];
this.port = source["port"];
this.com = source["com"];
this.baud = source["baud"];
this.follow = source["follow"];
this.step_khz = source["step_khz"];
}
+363
View File
@@ -0,0 +1,363 @@
// Package steppir controls a SteppIR SDA-100 / SDA-2000 antenna controller over
// its "Transceiver Interface" serial protocol, reached either directly on a COM
// port or over TCP through an RS232↔Ethernet bridge (the same way OpsLog talks to
// an Ultrabeam). The client mirrors the ultrabeam.Client surface so the app can
// drive either behind one interface.
//
// Protocol (cross-checked against the SteppIR "Transceiver Interface Operation"
// note, the we7u/steppir library, and the la1k.no write-up — three independent
// sources that agree, which is what makes the byte layout trustworthy):
//
// SET : "@A" <freq> 00 <dir> <cmd> 00 0x0D (11 bytes)
// <freq> = int32 big-endian of (Hz / 10)
// <dir> = 0x00 normal · 0x40 180° · 0x80 bidirectional · 0x20 3/4-wave
// <cmd> = '1' set freq+dir · 'R' autotrack ON · 'U' autotrack OFF
// 'S' home/retract · 'V' calibrate
// STATUS: "?A" 0x0D → 11 bytes back:
// [2:6] int32 big-endian frequency (× 10 = Hz)
// [6] active-motor bitmask (0xFF = command received / setup)
// [7] & 0xE0 direction
//
// Timing: the controller needs ≥100 ms between commands and dislikes status
// polls faster than ~10/s. The poll loop runs at 2 s, well inside that.
package steppir
import (
"encoding/binary"
"fmt"
"io"
"log"
"net"
"sync"
"time"
"go.bug.st/serial"
)
// Direction values, matching the app-wide convention (also used by Ultrabeam):
// 0 normal, 1 reverse (180°), 2 bidirectional.
const (
DirNormal = 0
Dir180 = 1
DirBi = 2
)
// SteppIR direction bytes on the wire.
const (
wireNormal = 0x00
wire180 = 0x40
wireBi = 0x80
)
// Transport says how to reach the controller.
type Transport struct {
Mode string // "tcp" | "serial"
Host string // tcp
Port int // tcp
COM string // serial device (COM3, /dev/ttyUSB0)
Baud int // serial baud (controller default 9600; 1200-19200 valid)
}
// Status is the antenna state, in the same shape the app reads from the
// Ultrabeam so the two are interchangeable at the UI.
type Status struct {
Connected bool `json:"connected"`
Frequency int `json:"frequency"` // kHz
Band int `json:"band"` // 0 (SteppIR does not report a band index)
Direction int `json:"direction"` // 0 normal, 1 180°, 2 bidirectional
MotorsMoving int `json:"motors_moving"`
}
type Client struct {
tr Transport
connMu sync.Mutex
conn io.ReadWriteCloser
statusMu sync.RWMutex
lastStatus *Status
lastSetKHz int
// A just-commanded direction is held until the controller's poll reports it —
// the motors take a second or two, and a stale poll would otherwise snap the
// UI back. Same trick as the Ultrabeam client.
pendingDir int
pendingDirAt time.Time
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{}
running bool
}
func New(tr Transport) *Client {
if tr.Baud <= 0 {
tr.Baud = 9600
}
return &Client{tr: tr, stopChan: make(chan struct{})}
}
func (c *Client) Start() error {
c.running = true
go c.pollLoop()
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stopChan)
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connMu.Unlock()
}
// LastSetKHz returns the frequency last commanded, or 0.
func (c *Client) LastSetKHz() int {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.lastSetKHz
}
func (c *Client) GetStatus() (*Status, error) {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
if c.lastStatus == nil {
return &Status{Connected: false}, nil
}
return c.lastStatus, nil
}
// open dials the transport. Callers hold connMu.
func (c *Client) open() (io.ReadWriteCloser, error) {
switch c.tr.Mode {
case "serial":
if c.tr.COM == "" {
return nil, fmt.Errorf("steppir: no serial port configured")
}
p, err := serial.Open(c.tr.COM, &serial.Mode{BaudRate: c.tr.Baud})
if err != nil {
return nil, err
}
// A finite read timeout so a silent controller doesn't wedge the poll loop.
_ = p.SetReadTimeout(2 * time.Second)
return p, nil
default: // tcp
if c.tr.Host == "" {
return nil, fmt.Errorf("steppir: no host configured")
}
d := net.Dialer{Timeout: 5 * time.Second}
return d.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port)))
}
}
func (c *Client) pollLoop() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-c.stopChan:
return
case <-ticker.C:
c.connMu.Lock()
if c.conn == nil {
conn, err := c.open()
if err != nil {
c.connMu.Unlock()
c.setDisconnected()
continue
}
c.conn = conn
}
c.connMu.Unlock()
st, err := c.queryStatus()
if err != nil {
log.Printf("steppir: status query failed, reconnecting: %v", err)
c.closeConn()
c.setDisconnected()
continue
}
st.Connected = true
c.statusMu.Lock()
if c.pendingDirSet {
if time.Since(c.pendingDirAt) > 4*time.Second || st.Direction == c.pendingDir {
c.pendingDirSet = false
} else {
st.Direction = c.pendingDir
}
}
c.lastStatus = st
c.statusMu.Unlock()
}
}
}
func (c *Client) setDisconnected() {
c.statusMu.Lock()
c.lastStatus = &Status{Connected: false}
c.statusMu.Unlock()
}
func (c *Client) closeConn() {
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connMu.Unlock()
}
// setDeadline applies a read/write deadline on TCP; serial uses its own timeout.
func setDeadline(conn io.ReadWriteCloser, d time.Duration) {
if nc, ok := conn.(net.Conn); ok {
_ = nc.SetDeadline(time.Now().Add(d))
}
}
func (c *Client) queryStatus() (*Status, error) {
c.connMu.Lock()
conn := c.conn
c.connMu.Unlock()
if conn == nil {
return nil, fmt.Errorf("steppir: not connected")
}
setDeadline(conn, 3*time.Second)
if _, err := conn.Write([]byte("?A\r")); err != nil {
return nil, fmt.Errorf("write status cmd: %w", err)
}
buf := make([]byte, 11)
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, fmt.Errorf("read status: %w", err)
}
return parseStatus(buf)
}
// parseStatus decodes an 11-byte status frame.
func parseStatus(b []byte) (*Status, error) {
if len(b) < 11 {
return nil, fmt.Errorf("steppir: short status frame (%d bytes)", len(b))
}
freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10
active := b[6]
dir := decodeDir(b[7])
// active==0xFF means "command just received" (not motion); the 0x01 bit is
// documented as always set. Treat anything else non-zero as motors busy.
moving := 0
if active != 0xFF && (active & ^byte(0x01)) != 0 {
moving = 1
}
return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil
}
func decodeDir(b byte) int {
switch b & 0xE0 {
case wireBi:
return DirBi
case wire180:
return Dir180
default:
return DirNormal
}
}
func dirWireByte(dir int) byte {
switch dir {
case Dir180:
return wire180
case DirBi:
return wireBi
default:
return wireNormal
}
}
// buildSet frames a SET command: "@A" <freq be32 of Hz/10> 00 <dir> <cmd> 00 CR.
func buildSet(freqHz int, dir int, cmd byte) []byte {
var f [4]byte
binary.BigEndian.PutUint32(f[:], uint32(freqHz/10))
out := make([]byte, 0, 11)
out = append(out, '@', 'A')
out = append(out, f[:]...)
out = append(out, 0x00, dirWireByte(dir), cmd, 0x00, 0x0D)
return out
}
func (c *Client) writeCmd(pkt []byte) error {
c.connMu.Lock()
conn := c.conn
c.connMu.Unlock()
if conn == nil {
return fmt.Errorf("steppir: not connected")
}
setDeadline(conn, 3*time.Second)
if _, err := conn.Write(pkt); err != nil {
c.closeConn()
return err
}
// The controller needs breathing room between commands.
time.Sleep(120 * time.Millisecond)
return nil
}
// SetFrequency tunes the elements to freqKhz with the given direction. If a prior
// Retract dropped AUTOTRACK, re-enable it first — otherwise the set is ignored.
func (c *Client) SetFrequency(freqKhz int, direction int) error {
if c.needAutotrack {
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil {
return err
}
c.needAutotrack = false
}
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil {
return err
}
c.statusMu.Lock()
c.lastSetKHz = freqKhz
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
c.statusMu.Unlock()
return nil
}
// SetDirection changes the pattern. SteppIR has no standalone direction command —
// it is a SET with the current frequency and the new direction byte.
func (c *Client) SetDirection(direction int) error {
khz := c.LastSetKHz()
if khz <= 0 {
if st, _ := c.GetStatus(); st != nil {
khz = st.Frequency
}
}
if khz <= 0 {
return fmt.Errorf("steppir: no frequency known yet — cannot set direction")
}
return c.SetFrequency(khz, direction)
}
// Retract homes the elements into the hubs (storage). This leaves AUTOTRACK off,
// so the next SetFrequency re-enables it.
func (c *Client) Retract() error {
// A valid frequency must accompany the command; reuse the last one.
khz := c.LastSetKHz()
if khz <= 0 {
if st, _ := c.GetStatus(); st != nil && st.Frequency > 0 {
khz = st.Frequency
} else {
khz = 14000 // any in-range value; the controller just homes
}
}
if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'S')); err != nil {
return err
}
c.needAutotrack = true
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package steppir
import (
"encoding/binary"
"testing"
)
// The exact bytes are the correctness checksum. If buildSet ever drifts from the
// three-source-agreed layout, this fails — a wrong packet is a silently mistuned
// antenna, far worse than a compile error.
func TestBuildSetLayout(t *testing.T) {
// 14.074 MHz, normal, set-freq. freq/10 = 1_407_400 = 0x00 0x15 0x79 0xA8.
pkt := buildSet(14_074_000, DirNormal, '1')
want := []byte{'@', 'A', 0x00, 0x15, 0x79, 0xA8, 0x00, 0x00, '1', 0x00, 0x0D}
if len(pkt) != 11 {
t.Fatalf("packet is %d bytes, want 11", len(pkt))
}
for i := range want {
if pkt[i] != want[i] {
t.Fatalf("byte %d = 0x%02X, want 0x%02X\n got %X\nwant %X", i, pkt[i], want[i], pkt, want)
}
}
// Frequency must round-trip: bytes [2:6] × 10 = Hz.
if got := int(binary.BigEndian.Uint32(pkt[2:6])) * 10; got != 14_074_000 {
t.Fatalf("freq round-trip = %d, want 14074000", got)
}
}
func TestBuildSetDirectionAndCommand(t *testing.T) {
cases := []struct {
dir int
cmd byte
wantDir byte
wantCmd byte
}{
{DirNormal, '1', 0x00, '1'},
{Dir180, '1', 0x40, '1'},
{DirBi, '1', 0x80, '1'},
{DirNormal, 'S', 0x00, 'S'}, // retract / home
{DirNormal, 'R', 0x00, 'R'}, // autotrack on
}
for _, c := range cases {
pkt := buildSet(21_000_000, c.dir, c.cmd)
if pkt[7] != c.wantDir {
t.Errorf("dir %d → byte 0x%02X, want 0x%02X", c.dir, pkt[7], c.wantDir)
}
if pkt[8] != c.wantCmd {
t.Errorf("cmd %q → byte 0x%02X, want 0x%02X", c.cmd, pkt[8], c.wantCmd)
}
}
}
// parseStatus decodes what the controller sends back — the inverse of buildSet's
// frequency field, plus the direction nibble.
func TestParseStatus(t *testing.T) {
frame := []byte{0x00, 0x00, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '1', '2', 0x0D}
st, err := parseStatus(frame)
if err != nil {
t.Fatal(err)
}
if st.Frequency != 14074 {
t.Errorf("freq = %d kHz, want 14074", st.Frequency)
}
if st.Direction != Dir180 {
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
}
if st.MotorsMoving != 0 { // 0x01 is the always-set bit, not motion
t.Errorf("moving = %d, want 0 (only the always-on bit set)", st.MotorsMoving)
}
// Motors busy: a bit beyond 0x01 is set.
frame[6] = 0x07
st, _ = parseStatus(frame)
if st.MotorsMoving == 0 {
t.Error("active-motors 0x07 should read as moving")
}
// 0xFF is "command received", not motion.
frame[6] = 0xFF
st, _ = parseStatus(frame)
if st.MotorsMoving != 0 {
t.Error("active-motors 0xFF (command received) must not read as moving")
}
}