feat: While closing OpsLog will keep the same size and position for next launch
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 1–2 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 1–2 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.",
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user